@dfosco/storyboard 0.6.1 → 0.6.3
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/assets/favicon.svg +12 -0
- package/package.json +2 -1
- package/src/core/canvas/githubEmbeds.js +112 -3
- package/src/core/canvas/githubEmbeds.test.js +172 -0
- package/src/core/vite/server-plugin.js +23 -0
- package/src/internals/canvas/widgets/LinkPreview.jsx +45 -1
- package/src/internals/canvas/widgets/LinkPreview.module.css +71 -0
- package/src/internals/canvas/widgets/githubUrl.js +20 -4
- package/src/internals/canvas/widgets/githubUrl.test.js +24 -0
- package/src/internals/vite/data-plugin.js +7 -2
- package/src/internals/vite/data-plugin.test.js +8 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<svg width="1040" height="1040" viewBox="0 0 1040 1040" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<g clip-path="url(#clip0_281_5346)">
|
|
3
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M1040 520C1040 607.255 1040.14 680.911 1033.5 740.187C1026.63 801.432 1011.5 859.346 972.737 910.26C954.816 933.8 933.8 954.816 910.26 972.737C859.346 1011.5 801.432 1026.63 740.187 1033.5C680.911 1040.14 607.255 1040 520 1040C432.747 1040 359.09 1040.14 299.813 1033.5C238.568 1026.63 180.656 1011.5 129.74 972.737C106.201 954.816 85.1857 933.8 67.263 910.26C28.496 859.346 13.3667 801.432 6.50058 740.187C-0.144753 680.911 0.000253571 607.255 0.000253571 520C0.000253571 432.747 -0.144754 359.09 6.50058 299.813C13.3667 238.568 28.496 180.656 67.263 129.74C85.1857 106.201 106.201 85.1857 129.74 67.263C180.656 28.496 238.568 13.3667 299.813 6.50058C359.09 -0.144754 432.747 0.000253571 520 0.000253571C607.255 0.000253571 680.911 -0.144753 740.187 6.50058C801.432 13.3667 859.346 28.496 910.26 67.263C933.8 85.1857 954.816 106.201 972.737 129.74C1011.5 180.656 1026.63 238.568 1033.5 299.813C1040.14 359.09 1040 432.747 1040 520Z" fill="#170C1F"/>
|
|
4
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M984 520.5C984 325.013 984 226.507 933.401 158.522L932.197 156.924C918.393 138.793 902.207 122.608 884.078 108.804C816.039 56.9996 717.525 57 520.5 57C323.474 57 224.961 56.9996 156.924 108.804L155.229 110.105C137.784 123.621 122.176 139.36 108.804 156.924L107.6 158.522C57 226.507 57 325.013 57 520.5C57 717.525 56.9997 816.039 108.804 884.078C122.608 902.207 138.793 918.393 156.924 932.197C224.961 984 323.474 984 520.5 984C715.986 984 814.495 984 882.477 933.401L884.078 932.197C901.64 918.824 917.379 903.215 930.895 885.771L932.197 884.078C984 816.039 984 717.525 984 520.5Z" fill="#170C1F"/>
|
|
5
|
+
<path fill-rule="evenodd" clip-rule="evenodd" d="M208.988 378.291C208.988 407.969 220.778 436.431 241.763 457.416C262.748 478.401 291.21 490.19 320.887 490.19C350.564 490.19 379.026 478.401 400.011 457.416C420.997 436.431 432.786 407.969 432.786 378.291C432.786 348.614 420.997 320.152 400.011 299.167C379.026 278.182 350.564 266.393 320.887 266.393C291.21 266.393 262.748 278.182 241.763 299.167C220.778 320.152 208.988 348.614 208.988 378.291ZM605.719 378.291C605.719 392.986 608.613 407.537 614.237 421.113C619.86 434.689 628.102 447.025 638.493 457.416C648.884 467.806 661.219 476.049 674.796 481.672C688.372 487.296 702.923 490.19 717.617 490.19C732.312 490.19 746.863 487.296 760.439 481.672C774.015 476.049 786.351 467.806 796.742 457.416C807.133 447.025 815.375 434.689 820.998 421.113C826.622 407.537 829.516 392.986 829.516 378.291C829.516 363.597 826.622 349.046 820.998 335.47C815.375 321.893 807.133 309.558 796.742 299.167C786.351 288.776 774.015 280.534 760.439 274.91C746.863 269.287 732.312 266.393 717.617 266.393C702.923 266.393 688.372 269.287 674.796 274.91C661.219 280.534 648.884 288.776 638.493 299.167C628.102 309.558 619.86 321.893 614.237 335.47C608.613 349.046 605.719 363.597 605.719 378.291ZM631.928 541.203C646.198 541.203 658.91 553.016 656.488 568.2C645.989 634.034 589.271 684.367 520.824 684.367C452.376 684.367 395.658 634.034 385.16 568.2C382.737 553.016 395.449 541.203 409.72 541.203C424.454 541.203 435.266 553.171 438.371 566.171C447.328 603.669 480.868 631.497 520.824 631.497C560.78 631.497 594.32 603.669 603.277 566.171C606.382 553.171 617.193 541.203 631.928 541.203Z" fill="#EBD7EF"/>
|
|
6
|
+
</g>
|
|
7
|
+
<defs>
|
|
8
|
+
<clipPath id="clip0_281_5346">
|
|
9
|
+
<rect width="1040" height="1040" fill="white"/>
|
|
10
|
+
</clipPath>
|
|
11
|
+
</defs>
|
|
12
|
+
</svg>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dfosco/storyboard",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "Storyboard prototyping framework — core engine, React integration, and canvas",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"src",
|
|
19
19
|
"dist",
|
|
20
|
+
"assets",
|
|
20
21
|
"scaffold",
|
|
21
22
|
"mascot",
|
|
22
23
|
"toolbar.config.json",
|
|
@@ -10,6 +10,7 @@ const DISCUSSION_PATH_RE = /^\/([^/]+)\/([^/]+)\/discussions\/(\d+)\/?$/
|
|
|
10
10
|
const PULL_REQUEST_PATH_RE = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/
|
|
11
11
|
const ISSUE_COMMENT_HASH_RE = /^#issuecomment-(\d+)$/i
|
|
12
12
|
const DISCUSSION_COMMENT_HASH_RE = /^#discussioncomment-(\d+)$/i
|
|
13
|
+
const PR_REVIEW_COMMENT_HASH_RE = /^#discussion_r(\d+)$/i
|
|
13
14
|
|
|
14
15
|
const GH_TIMEOUT_MS = 15_000
|
|
15
16
|
const NOT_FOUND_MESSAGE = 'GitHub resource was not found or is not accessible with current credentials.'
|
|
@@ -146,11 +147,16 @@ function toContext(target) {
|
|
|
146
147
|
if (target.kind === 'pull_request') return `GitHub · ${repo} · Pull Request #${target.number}`
|
|
147
148
|
if (target.kind === 'discussion') return `GitHub · ${repo} · Discussion #${target.number}`
|
|
148
149
|
if (target.parentKind === 'issue') return `GitHub · ${repo} · Issue #${target.number} comment`
|
|
150
|
+
if (target.parentKind === 'pull_request') return `GitHub · ${repo} · Pull Request #${target.number} comment`
|
|
149
151
|
return `GitHub · ${repo} · Discussion #${target.number} comment`
|
|
150
152
|
}
|
|
151
153
|
|
|
152
154
|
function buildPullRequestSnapshot(target, pr) {
|
|
153
155
|
const number = pr?.number ?? target.number
|
|
156
|
+
const merged = Boolean(pr?.merged_at || pr?.merged)
|
|
157
|
+
const draft = Boolean(pr?.draft)
|
|
158
|
+
const rawState = pr?.state === 'closed' ? 'closed' : 'open'
|
|
159
|
+
const state = merged ? 'merged' : (draft && rawState === 'open' ? 'draft' : rawState)
|
|
154
160
|
return {
|
|
155
161
|
kind: 'pull_request',
|
|
156
162
|
parentKind: 'pull_request',
|
|
@@ -162,6 +168,14 @@ function buildPullRequestSnapshot(target, pr) {
|
|
|
162
168
|
createdAt: pr?.created_at ?? null,
|
|
163
169
|
updatedAt: pr?.updated_at ?? null,
|
|
164
170
|
url: target.url,
|
|
171
|
+
state,
|
|
172
|
+
merged,
|
|
173
|
+
draft,
|
|
174
|
+
baseRef: typeof pr?.base?.ref === 'string' ? pr.base.ref : null,
|
|
175
|
+
headRef: typeof pr?.head?.ref === 'string' ? pr.head.ref : null,
|
|
176
|
+
additions: typeof pr?.additions === 'number' ? pr.additions : null,
|
|
177
|
+
deletions: typeof pr?.deletions === 'number' ? pr.deletions : null,
|
|
178
|
+
changedFiles: typeof pr?.changed_files === 'number' ? pr.changed_files : null,
|
|
165
179
|
}
|
|
166
180
|
}
|
|
167
181
|
|
|
@@ -197,6 +211,23 @@ function buildDiscussionSnapshot(target, discussion) {
|
|
|
197
211
|
}
|
|
198
212
|
}
|
|
199
213
|
|
|
214
|
+
function buildPullRequestCommentSnapshot(target, comment, pr) {
|
|
215
|
+
const prNumber = pr?.number ?? target.number
|
|
216
|
+
const parentLabel = pr?.title ? `#${prNumber} ${pr.title}` : `Pull Request #${prNumber}`
|
|
217
|
+
return {
|
|
218
|
+
kind: 'comment',
|
|
219
|
+
parentKind: 'pull_request',
|
|
220
|
+
context: toContext(target),
|
|
221
|
+
title: `Comment on ${parentLabel}`,
|
|
222
|
+
body: normalizeBody(comment?.body),
|
|
223
|
+
bodyHtml: comment?.body_html || '',
|
|
224
|
+
authors: uniqueStrings([comment?.user?.login, pr?.user?.login]),
|
|
225
|
+
createdAt: comment?.created_at ?? null,
|
|
226
|
+
updatedAt: comment?.updated_at ?? null,
|
|
227
|
+
url: target.url,
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
200
231
|
function buildIssueCommentSnapshot(target, comment, issue) {
|
|
201
232
|
const issueNumber = issue?.number ?? target.number
|
|
202
233
|
const parentLabel = issue?.title ? `#${issueNumber} ${issue.title}` : `Issue #${issueNumber}`
|
|
@@ -249,6 +280,20 @@ function parseIssueRefFromApiUrl(issueUrl) {
|
|
|
249
280
|
}
|
|
250
281
|
}
|
|
251
282
|
|
|
283
|
+
function parsePullRequestRefFromApiUrl(prUrl) {
|
|
284
|
+
if (typeof prUrl !== 'string' || !prUrl) return null
|
|
285
|
+
try {
|
|
286
|
+
const parsed = new URL(prUrl)
|
|
287
|
+
const match = parsed.pathname.match(/^\/repos\/([^/]+)\/([^/]+)\/pulls\/(\d+)$/)
|
|
288
|
+
if (!match) return null
|
|
289
|
+
const number = parseNumber(match[3])
|
|
290
|
+
if (!number) return null
|
|
291
|
+
return { owner: match[1], repo: match[2], number }
|
|
292
|
+
} catch {
|
|
293
|
+
return null
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
252
297
|
function assertIssueCommentParent(target, comment) {
|
|
253
298
|
const parent = parseIssueRefFromApiUrl(comment?.issue_url)
|
|
254
299
|
if (!parent) {
|
|
@@ -263,6 +308,24 @@ function assertIssueCommentParent(target, comment) {
|
|
|
263
308
|
}
|
|
264
309
|
}
|
|
265
310
|
|
|
311
|
+
function assertPullRequestCommentParent(target, comment) {
|
|
312
|
+
// Top-level PR conversation comments come back via issues/comments endpoint and
|
|
313
|
+
// expose issue_url that matches the PR's issue (/repos/.../issues/N).
|
|
314
|
+
// Inline review comments expose pull_request_url (/repos/.../pulls/N).
|
|
315
|
+
const parent = parsePullRequestRefFromApiUrl(comment?.pull_request_url)
|
|
316
|
+
|| parseIssueRefFromApiUrl(comment?.issue_url)
|
|
317
|
+
if (!parent) {
|
|
318
|
+
throw new GitHubEmbedError('gh_not_found', NOT_FOUND_MESSAGE, 404)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const sameOwner = parent.owner.toLowerCase() === target.owner.toLowerCase()
|
|
322
|
+
const sameRepo = parent.repo.toLowerCase() === target.repo.toLowerCase()
|
|
323
|
+
const sameNumber = parent.number === target.number
|
|
324
|
+
if (!sameOwner || !sameRepo || !sameNumber) {
|
|
325
|
+
throw new GitHubEmbedError('gh_not_found', NOT_FOUND_MESSAGE, 404)
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
266
329
|
function fetchDiscussion(target) {
|
|
267
330
|
const query = `
|
|
268
331
|
query($owner: String!, $repo: String!, $number: Int!) {
|
|
@@ -356,17 +419,21 @@ query($owner: String!, $repo: String!, $number: Int!, $after: String) {
|
|
|
356
419
|
* Supported patterns:
|
|
357
420
|
* - /{owner}/{repo}/issues/{number}
|
|
358
421
|
* - /{owner}/{repo}/discussions/{number}
|
|
422
|
+
* - /{owner}/{repo}/pull/{number}
|
|
359
423
|
* - /{owner}/{repo}/issues/{number}#issuecomment-{id}
|
|
360
424
|
* - /{owner}/{repo}/discussions/{number}#discussioncomment-{id}
|
|
425
|
+
* - /{owner}/{repo}/pull/{number}#issuecomment-{id} (top-level PR comment)
|
|
426
|
+
* - /{owner}/{repo}/pull/{number}#discussion_r{id} (inline review comment)
|
|
361
427
|
*
|
|
362
428
|
* @param {string} rawUrl
|
|
363
429
|
* @returns {null | {
|
|
364
|
-
* kind: 'issue' | 'discussion' | 'comment',
|
|
365
|
-
* parentKind: 'issue' | 'discussion',
|
|
430
|
+
* kind: 'issue' | 'discussion' | 'pull_request' | 'comment',
|
|
431
|
+
* parentKind: 'issue' | 'discussion' | 'pull_request',
|
|
366
432
|
* owner: string,
|
|
367
433
|
* repo: string,
|
|
368
434
|
* number: number,
|
|
369
435
|
* commentId?: number,
|
|
436
|
+
* commentSubKind?: 'issue_comment' | 'review_comment',
|
|
370
437
|
* url: string
|
|
371
438
|
* }}
|
|
372
439
|
*/
|
|
@@ -448,6 +515,38 @@ export function parseGitHubEmbedUrl(rawUrl) {
|
|
|
448
515
|
const number = parseNumber(numberRaw)
|
|
449
516
|
if (!number) return null
|
|
450
517
|
|
|
518
|
+
const issueCommentMatch = parsed.hash.match(ISSUE_COMMENT_HASH_RE)
|
|
519
|
+
if (issueCommentMatch) {
|
|
520
|
+
const commentId = parseNumber(issueCommentMatch[1])
|
|
521
|
+
if (!commentId) return null
|
|
522
|
+
return {
|
|
523
|
+
kind: 'comment',
|
|
524
|
+
parentKind: 'pull_request',
|
|
525
|
+
commentSubKind: 'issue_comment',
|
|
526
|
+
owner,
|
|
527
|
+
repo,
|
|
528
|
+
number,
|
|
529
|
+
commentId,
|
|
530
|
+
url: parsed.toString(),
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const reviewCommentMatch = parsed.hash.match(PR_REVIEW_COMMENT_HASH_RE)
|
|
535
|
+
if (reviewCommentMatch) {
|
|
536
|
+
const commentId = parseNumber(reviewCommentMatch[1])
|
|
537
|
+
if (!commentId) return null
|
|
538
|
+
return {
|
|
539
|
+
kind: 'comment',
|
|
540
|
+
parentKind: 'pull_request',
|
|
541
|
+
commentSubKind: 'review_comment',
|
|
542
|
+
owner,
|
|
543
|
+
repo,
|
|
544
|
+
number,
|
|
545
|
+
commentId,
|
|
546
|
+
url: parsed.toString(),
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
451
550
|
if (parsed.hash) return null
|
|
452
551
|
return {
|
|
453
552
|
kind: 'pull_request',
|
|
@@ -483,7 +582,7 @@ export function fetchGitHubEmbedSnapshot(rawUrl) {
|
|
|
483
582
|
if (!target) {
|
|
484
583
|
throw new GitHubEmbedError(
|
|
485
584
|
'unsupported_url',
|
|
486
|
-
'Only GitHub issue, discussion, and comment URLs are supported.',
|
|
585
|
+
'Only GitHub issue, discussion, pull request, and comment URLs are supported.',
|
|
487
586
|
400,
|
|
488
587
|
)
|
|
489
588
|
}
|
|
@@ -519,6 +618,16 @@ export function fetchGitHubEmbedSnapshot(rawUrl) {
|
|
|
519
618
|
return buildIssueCommentSnapshot(target, comment, issue)
|
|
520
619
|
}
|
|
521
620
|
|
|
621
|
+
if (target.parentKind === 'pull_request') {
|
|
622
|
+
const endpoint = target.commentSubKind === 'review_comment'
|
|
623
|
+
? `repos/${target.owner}/${target.repo}/pulls/comments/${target.commentId}`
|
|
624
|
+
: `repos/${target.owner}/${target.repo}/issues/comments/${target.commentId}`
|
|
625
|
+
const comment = runGhApi(endpoint, { withHtml: true })
|
|
626
|
+
assertPullRequestCommentParent(target, comment)
|
|
627
|
+
const pr = runGhApi(`repos/${target.owner}/${target.repo}/pulls/${target.number}`, { withHtml: true })
|
|
628
|
+
return buildPullRequestCommentSnapshot(target, comment, pr)
|
|
629
|
+
}
|
|
630
|
+
|
|
522
631
|
const { comment, discussion } = fetchDiscussionComment(target)
|
|
523
632
|
return buildDiscussionCommentSnapshot(target, comment, discussion)
|
|
524
633
|
} catch (error) {
|
|
@@ -76,6 +76,32 @@ describe('parseGitHubEmbedUrl', () => {
|
|
|
76
76
|
})
|
|
77
77
|
})
|
|
78
78
|
|
|
79
|
+
it('parses PR top-level comment links', () => {
|
|
80
|
+
expect(parseGitHubEmbedUrl('https://github.com/dfosco/storyboard/pull/789#issuecomment-111')).toEqual({
|
|
81
|
+
kind: 'comment',
|
|
82
|
+
parentKind: 'pull_request',
|
|
83
|
+
commentSubKind: 'issue_comment',
|
|
84
|
+
owner: 'dfosco',
|
|
85
|
+
repo: 'storyboard',
|
|
86
|
+
number: 789,
|
|
87
|
+
commentId: 111,
|
|
88
|
+
url: 'https://github.com/dfosco/storyboard/pull/789#issuecomment-111',
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('parses PR inline review comment links', () => {
|
|
93
|
+
expect(parseGitHubEmbedUrl('https://github.com/dfosco/storyboard/pull/789#discussion_r222')).toEqual({
|
|
94
|
+
kind: 'comment',
|
|
95
|
+
parentKind: 'pull_request',
|
|
96
|
+
commentSubKind: 'review_comment',
|
|
97
|
+
owner: 'dfosco',
|
|
98
|
+
repo: 'storyboard',
|
|
99
|
+
number: 789,
|
|
100
|
+
commentId: 222,
|
|
101
|
+
url: 'https://github.com/dfosco/storyboard/pull/789#discussion_r222',
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
79
105
|
it('rejects unsupported URLs and hashes', () => {
|
|
80
106
|
expect(parseGitHubEmbedUrl('https://github.com/dfosco/storyboard/issues/123#foo')).toBeNull()
|
|
81
107
|
expect(parseGitHubEmbedUrl('https://example.com/dfosco/storyboard/issues/123')).toBeNull()
|
|
@@ -176,6 +202,76 @@ describe('fetchGitHubEmbedSnapshot', () => {
|
|
|
176
202
|
expect(graphqlArgs.slice(0, 2)).toEqual(['api', 'graphql'])
|
|
177
203
|
})
|
|
178
204
|
|
|
205
|
+
it('hydrates pull request snapshot metadata (state, branches, diff)', () => {
|
|
206
|
+
ghExec
|
|
207
|
+
.mockReturnValueOnce('gh version 2.58.0')
|
|
208
|
+
.mockReturnValueOnce(JSON.stringify({
|
|
209
|
+
number: 79,
|
|
210
|
+
title: 'fix(viewfinder): eliminate FOUC',
|
|
211
|
+
body: 'A PR body',
|
|
212
|
+
state: 'closed',
|
|
213
|
+
merged: true,
|
|
214
|
+
merged_at: '2026-04-10T00:00:00Z',
|
|
215
|
+
draft: false,
|
|
216
|
+
user: { login: 'dfosco' },
|
|
217
|
+
created_at: '2026-04-01T00:00:00Z',
|
|
218
|
+
updated_at: '2026-04-10T00:00:00Z',
|
|
219
|
+
base: { ref: '3.11.0' },
|
|
220
|
+
head: { ref: '3.11.0--fix-viewfinder-fouc' },
|
|
221
|
+
additions: 63,
|
|
222
|
+
deletions: 18,
|
|
223
|
+
changed_files: 4,
|
|
224
|
+
}))
|
|
225
|
+
|
|
226
|
+
const snapshot = fetchGitHubEmbedSnapshot('https://github.com/dfosco/storyboard/pull/79')
|
|
227
|
+
|
|
228
|
+
expect(snapshot).toMatchObject({
|
|
229
|
+
kind: 'pull_request',
|
|
230
|
+
parentKind: 'pull_request',
|
|
231
|
+
title: '#79 fix(viewfinder): eliminate FOUC',
|
|
232
|
+
state: 'merged',
|
|
233
|
+
merged: true,
|
|
234
|
+
draft: false,
|
|
235
|
+
baseRef: '3.11.0',
|
|
236
|
+
headRef: '3.11.0--fix-viewfinder-fouc',
|
|
237
|
+
additions: 63,
|
|
238
|
+
deletions: 18,
|
|
239
|
+
changedFiles: 4,
|
|
240
|
+
})
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('marks draft PRs and unmerged closed PRs correctly', () => {
|
|
244
|
+
ghExec
|
|
245
|
+
.mockReturnValueOnce('gh version 2.58.0')
|
|
246
|
+
.mockReturnValueOnce(JSON.stringify({
|
|
247
|
+
number: 5,
|
|
248
|
+
title: 'WIP: refactor',
|
|
249
|
+
state: 'open',
|
|
250
|
+
draft: true,
|
|
251
|
+
merged: false,
|
|
252
|
+
user: { login: 'dfosco' },
|
|
253
|
+
}))
|
|
254
|
+
|
|
255
|
+
const draftSnap = fetchGitHubEmbedSnapshot('https://github.com/dfosco/storyboard/pull/5')
|
|
256
|
+
expect(draftSnap.state).toBe('draft')
|
|
257
|
+
expect(draftSnap.draft).toBe(true)
|
|
258
|
+
expect(draftSnap.merged).toBe(false)
|
|
259
|
+
|
|
260
|
+
ghExec
|
|
261
|
+
.mockReturnValueOnce('gh version 2.58.0')
|
|
262
|
+
.mockReturnValueOnce(JSON.stringify({
|
|
263
|
+
number: 6,
|
|
264
|
+
state: 'closed',
|
|
265
|
+
merged: false,
|
|
266
|
+
merged_at: null,
|
|
267
|
+
user: { login: 'dfosco' },
|
|
268
|
+
}))
|
|
269
|
+
|
|
270
|
+
const closedSnap = fetchGitHubEmbedSnapshot('https://github.com/dfosco/storyboard/pull/6')
|
|
271
|
+
expect(closedSnap.state).toBe('closed')
|
|
272
|
+
expect(closedSnap.merged).toBe(false)
|
|
273
|
+
})
|
|
274
|
+
|
|
179
275
|
it('rejects issue comment URLs when comment parent does not match', () => {
|
|
180
276
|
ghExec
|
|
181
277
|
.mockReturnValueOnce('gh version 2.58.0')
|
|
@@ -244,6 +340,82 @@ describe('fetchGitHubEmbedSnapshot', () => {
|
|
|
244
340
|
})
|
|
245
341
|
})
|
|
246
342
|
|
|
343
|
+
it('hydrates PR top-level comment snapshot metadata', () => { ghExec
|
|
344
|
+
.mockReturnValueOnce('gh version 2.58.0')
|
|
345
|
+
.mockReturnValueOnce(JSON.stringify({
|
|
346
|
+
body: 'Looks good!',
|
|
347
|
+
user: { login: 'reviewer' },
|
|
348
|
+
issue_url: 'https://api.github.com/repos/dfosco/storyboard/issues/789',
|
|
349
|
+
created_at: '2026-03-01T00:00:00Z',
|
|
350
|
+
updated_at: '2026-03-02T00:00:00Z',
|
|
351
|
+
}))
|
|
352
|
+
.mockReturnValueOnce(JSON.stringify({
|
|
353
|
+
number: 789,
|
|
354
|
+
title: 'Add PR comment embeds',
|
|
355
|
+
user: { login: 'dfosco' },
|
|
356
|
+
}))
|
|
357
|
+
|
|
358
|
+
const snapshot = fetchGitHubEmbedSnapshot('https://github.com/dfosco/storyboard/pull/789#issuecomment-111')
|
|
359
|
+
|
|
360
|
+
expect(snapshot).toEqual({
|
|
361
|
+
kind: 'comment',
|
|
362
|
+
parentKind: 'pull_request',
|
|
363
|
+
context: 'GitHub · dfosco/storyboard · Pull Request #789 comment',
|
|
364
|
+
title: 'Comment on #789 Add PR comment embeds',
|
|
365
|
+
body: 'Looks good!',
|
|
366
|
+
bodyHtml: '',
|
|
367
|
+
authors: ['reviewer', 'dfosco'],
|
|
368
|
+
createdAt: '2026-03-01T00:00:00Z',
|
|
369
|
+
updatedAt: '2026-03-02T00:00:00Z',
|
|
370
|
+
url: 'https://github.com/dfosco/storyboard/pull/789#issuecomment-111',
|
|
371
|
+
})
|
|
372
|
+
})
|
|
373
|
+
|
|
374
|
+
it('hydrates PR inline review comment snapshot metadata', () => {
|
|
375
|
+
ghExec
|
|
376
|
+
.mockReturnValueOnce('gh version 2.58.0')
|
|
377
|
+
.mockReturnValueOnce(JSON.stringify({
|
|
378
|
+
body: 'Nit: rename this var',
|
|
379
|
+
user: { login: 'reviewer' },
|
|
380
|
+
pull_request_url: 'https://api.github.com/repos/dfosco/storyboard/pulls/789',
|
|
381
|
+
created_at: '2026-03-05T00:00:00Z',
|
|
382
|
+
updated_at: '2026-03-05T00:00:00Z',
|
|
383
|
+
}))
|
|
384
|
+
.mockReturnValueOnce(JSON.stringify({
|
|
385
|
+
number: 789,
|
|
386
|
+
title: 'Add PR comment embeds',
|
|
387
|
+
user: { login: 'dfosco' },
|
|
388
|
+
}))
|
|
389
|
+
|
|
390
|
+
const snapshot = fetchGitHubEmbedSnapshot('https://github.com/dfosco/storyboard/pull/789#discussion_r222')
|
|
391
|
+
|
|
392
|
+
expect(snapshot.kind).toBe('comment')
|
|
393
|
+
expect(snapshot.parentKind).toBe('pull_request')
|
|
394
|
+
expect(snapshot.title).toBe('Comment on #789 Add PR comment embeds')
|
|
395
|
+
expect(snapshot.body).toBe('Nit: rename this var')
|
|
396
|
+
|
|
397
|
+
const reviewCommentArgs = ghExec.mock.calls[1][1]
|
|
398
|
+
expect(reviewCommentArgs.at(-1)).toBe('repos/dfosco/storyboard/pulls/comments/222')
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('rejects PR comment URLs when parent does not match', () => {
|
|
402
|
+
ghExec
|
|
403
|
+
.mockReturnValueOnce('gh version 2.58.0')
|
|
404
|
+
.mockReturnValueOnce(JSON.stringify({
|
|
405
|
+
body: 'Comment body',
|
|
406
|
+
user: { login: 'octocat' },
|
|
407
|
+
issue_url: 'https://api.github.com/repos/dfosco/storyboard/issues/999',
|
|
408
|
+
}))
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
fetchGitHubEmbedSnapshot('https://github.com/dfosco/storyboard/pull/789#issuecomment-111')
|
|
412
|
+
throw new Error('Expected fetchGitHubEmbedSnapshot to throw')
|
|
413
|
+
} catch (error) {
|
|
414
|
+
expect(error).toBeInstanceOf(GitHubEmbedError)
|
|
415
|
+
expect(error.code).toBe('gh_not_found')
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
|
|
247
419
|
it('throws gh_unavailable when gh is missing', () => {
|
|
248
420
|
ghExec.mockImplementation(() => {
|
|
249
421
|
throw new Error('spawn gh ENOENT')
|
|
@@ -110,6 +110,18 @@ export default function storyboardServer() {
|
|
|
110
110
|
const routeHandlers = new Map()
|
|
111
111
|
const clientScripts = []
|
|
112
112
|
|
|
113
|
+
// Load packaged favicon once and cache as data URI so every consumer
|
|
114
|
+
// gets the storyboard brand favicon without shipping/copying any asset.
|
|
115
|
+
let faviconDataUri = ''
|
|
116
|
+
try {
|
|
117
|
+
const faviconPath = path.resolve(
|
|
118
|
+
path.dirname(new URL(import.meta.url).pathname),
|
|
119
|
+
'../../../assets/favicon.svg'
|
|
120
|
+
)
|
|
121
|
+
const svg = fs.readFileSync(faviconPath, 'utf8')
|
|
122
|
+
faviconDataUri = 'data:image/svg+xml;base64,' + Buffer.from(svg, 'utf8').toString('base64')
|
|
123
|
+
} catch { /* favicon missing — skip injection */ }
|
|
124
|
+
|
|
113
125
|
return {
|
|
114
126
|
name: 'storyboard-server',
|
|
115
127
|
|
|
@@ -872,6 +884,17 @@ export default function storyboardServer() {
|
|
|
872
884
|
})
|
|
873
885
|
}
|
|
874
886
|
|
|
887
|
+
// Inject the storyboard brand favicon as a data URI. Appending to
|
|
888
|
+
// head means it wins over any earlier <link rel="icon"> the consumer
|
|
889
|
+
// declared (browsers use the last declaration).
|
|
890
|
+
if (faviconDataUri) {
|
|
891
|
+
tags.push({
|
|
892
|
+
tag: 'link',
|
|
893
|
+
attrs: { rel: 'icon', type: 'image/svg+xml', href: faviconDataUri },
|
|
894
|
+
injectTo: 'head',
|
|
895
|
+
})
|
|
896
|
+
}
|
|
897
|
+
|
|
875
898
|
return tags
|
|
876
899
|
},
|
|
877
900
|
|
|
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImper
|
|
|
2
2
|
import { remark } from 'remark'
|
|
3
3
|
import remarkGfm from 'remark-gfm'
|
|
4
4
|
import remarkHtml from 'remark-html'
|
|
5
|
-
import { MarkGithubIcon } from '@primer/octicons-react'
|
|
5
|
+
import { GitBranchIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, MarkGithubIcon } from '@primer/octicons-react'
|
|
6
6
|
import WidgetWrapper from './WidgetWrapper.jsx'
|
|
7
7
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
8
8
|
import { readProp, linkPreviewSchema } from './widgetProps.js'
|
|
@@ -119,6 +119,48 @@ function getCommentKindLabel(github) {
|
|
|
119
119
|
return 'Comment'
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
+
const PR_STATE_META = {
|
|
123
|
+
merged: { label: 'Merged', Icon: GitMergeIcon, className: 'prStateMerged' },
|
|
124
|
+
closed: { label: 'Closed', Icon: GitPullRequestClosedIcon, className: 'prStateClosed' },
|
|
125
|
+
draft: { label: 'Draft', Icon: GitPullRequestDraftIcon, className: 'prStateDraft' },
|
|
126
|
+
open: { label: 'Open', Icon: GitPullRequestIcon, className: 'prStateOpen' },
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function PullRequestMeta({ github }) {
|
|
130
|
+
if (github?.kind !== 'pull_request') return null
|
|
131
|
+
const stateKey = github?.state || 'open'
|
|
132
|
+
const stateMeta = PR_STATE_META[stateKey] || PR_STATE_META.open
|
|
133
|
+
const { Icon: StateIcon } = stateMeta
|
|
134
|
+
const baseRef = github?.baseRef || null
|
|
135
|
+
const headRef = github?.headRef || null
|
|
136
|
+
const additions = typeof github?.additions === 'number' ? github.additions : null
|
|
137
|
+
const deletions = typeof github?.deletions === 'number' ? github.deletions : null
|
|
138
|
+
const hasDiff = additions !== null || deletions !== null
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className={styles.prMeta}>
|
|
142
|
+
<span className={`${styles.prStateBadge} ${styles[stateMeta.className]}`}>
|
|
143
|
+
<StateIcon size={12} />
|
|
144
|
+
{stateMeta.label}
|
|
145
|
+
</span>
|
|
146
|
+
{(baseRef || headRef) && (
|
|
147
|
+
<span className={styles.prBranchRef} title={`${headRef || '?'} → ${baseRef || '?'}`}>
|
|
148
|
+
<GitBranchIcon size={12} />
|
|
149
|
+
<span className={styles.prBranchName}>{baseRef || '?'}</span>
|
|
150
|
+
<span className={styles.prBranchArrow}>←</span>
|
|
151
|
+
<span className={styles.prBranchName}>{headRef || '?'}</span>
|
|
152
|
+
</span>
|
|
153
|
+
)}
|
|
154
|
+
{hasDiff && (
|
|
155
|
+
<span className={styles.prDiffStat}>
|
|
156
|
+
{additions !== null && <span className={styles.prDiffAdd}>+{additions}</span>}
|
|
157
|
+
{deletions !== null && <span className={styles.prDiffDel}>-{deletions}</span>}
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
</div>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
|
|
122
164
|
function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, expandMode, onCloseExpand }) {
|
|
123
165
|
const authors = Array.isArray(github?.authors)
|
|
124
166
|
? github.authors.filter((a) => typeof a === 'string' && a.trim())
|
|
@@ -209,6 +251,7 @@ function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, e
|
|
|
209
251
|
{primaryAuthor && createdAgo ? ` opened ${createdAgo}` : createdAgo ? `Opened ${createdAgo}` : ''}
|
|
210
252
|
</span>
|
|
211
253
|
</div>
|
|
254
|
+
<PullRequestMeta github={github} />
|
|
212
255
|
</div>
|
|
213
256
|
|
|
214
257
|
{bodyHtml && (
|
|
@@ -243,6 +286,7 @@ function GitHubIssueCard({ id, url, title, github, width, collapsed, expanded, e
|
|
|
243
286
|
)}
|
|
244
287
|
{createdAgo && <span className={styles.expandedBylineText}>{primaryAuthor ? ` opened ${createdAgo}` : `Opened ${createdAgo}`}</span>}
|
|
245
288
|
</div>
|
|
289
|
+
<PullRequestMeta github={github} />
|
|
246
290
|
</header>
|
|
247
291
|
{bodyHtml && <div className={styles.expandedIssueBody} dangerouslySetInnerHTML={{ __html: bodyHtml }} />}
|
|
248
292
|
</div>
|
|
@@ -583,3 +583,74 @@
|
|
|
583
583
|
.expandedUrl:hover {
|
|
584
584
|
text-decoration: underline;
|
|
585
585
|
}
|
|
586
|
+
|
|
587
|
+
/* ── PR metadata (state badge + branch refs + diff stats) ──────────── */
|
|
588
|
+
|
|
589
|
+
.prMeta {
|
|
590
|
+
display: flex;
|
|
591
|
+
align-items: center;
|
|
592
|
+
gap: 8px;
|
|
593
|
+
flex-wrap: wrap;
|
|
594
|
+
font-size: 12px;
|
|
595
|
+
color: var(--fgColor-muted, #656d76);
|
|
596
|
+
min-width: 0;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
.prStateBadge {
|
|
600
|
+
display: inline-flex;
|
|
601
|
+
align-items: center;
|
|
602
|
+
gap: 4px;
|
|
603
|
+
padding: 3px 8px;
|
|
604
|
+
border-radius: 2em;
|
|
605
|
+
font-size: 12px;
|
|
606
|
+
font-weight: 500;
|
|
607
|
+
line-height: 1;
|
|
608
|
+
color: var(--fgColor-onEmphasis, #ffffff);
|
|
609
|
+
white-space: nowrap;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
.prStateOpen { background: var(--bgColor-open-emphasis, #1f883d); }
|
|
613
|
+
.prStateMerged { background: var(--bgColor-done-emphasis, #8250df); }
|
|
614
|
+
.prStateClosed { background: var(--bgColor-closed-emphasis, #cf222e); }
|
|
615
|
+
.prStateDraft {
|
|
616
|
+
background: var(--bgColor-neutral-emphasis, #59636e);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
.prBranchRef {
|
|
620
|
+
display: inline-flex;
|
|
621
|
+
align-items: center;
|
|
622
|
+
gap: 4px;
|
|
623
|
+
padding: 2px 6px;
|
|
624
|
+
border-radius: 4px;
|
|
625
|
+
background: var(--bgColor-muted, #f6f8fa);
|
|
626
|
+
color: var(--fgColor-default, #1f2328);
|
|
627
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
628
|
+
font-size: 11px;
|
|
629
|
+
max-width: 240px;
|
|
630
|
+
overflow: hidden;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
.prBranchName {
|
|
634
|
+
overflow: hidden;
|
|
635
|
+
text-overflow: ellipsis;
|
|
636
|
+
white-space: nowrap;
|
|
637
|
+
max-width: 96px;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
.prBranchArrow {
|
|
641
|
+
color: var(--fgColor-muted, #656d76);
|
|
642
|
+
font-family: -apple-system, system-ui, sans-serif;
|
|
643
|
+
flex-shrink: 0;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
.prDiffStat {
|
|
647
|
+
display: inline-flex;
|
|
648
|
+
align-items: center;
|
|
649
|
+
gap: 4px;
|
|
650
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
651
|
+
font-size: 12px;
|
|
652
|
+
font-weight: 600;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.prDiffAdd { color: var(--fgColor-success, #1a7f37); }
|
|
656
|
+
.prDiffDel { color: var(--fgColor-danger, #cf222e); }
|
|
@@ -4,6 +4,7 @@ const DISCUSSION_PATH_RE = /^\/([^/]+)\/([^/]+)\/discussions\/(\d+)\/?$/
|
|
|
4
4
|
const PULL_REQUEST_PATH_RE = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)\/?$/
|
|
5
5
|
const ISSUE_COMMENT_HASH_RE = /^#issuecomment-(\d+)$/i
|
|
6
6
|
const DISCUSSION_COMMENT_HASH_RE = /^#discussioncomment-(\d+)$/i
|
|
7
|
+
const PR_REVIEW_COMMENT_HASH_RE = /^#discussion_r(\d+)$/i
|
|
7
8
|
|
|
8
9
|
function toNumber(raw) {
|
|
9
10
|
const value = Number.parseInt(raw, 10)
|
|
@@ -11,15 +12,16 @@ function toNumber(raw) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* Parse supported GitHub embed URLs (issues, discussions, comments).
|
|
15
|
+
* Parse supported GitHub embed URLs (issues, discussions, pull requests, comments).
|
|
15
16
|
* @param {string} rawUrl
|
|
16
17
|
* @returns {null | {
|
|
17
|
-
* kind: 'issue' | 'discussion' | 'comment',
|
|
18
|
-
* parentKind: 'issue' | 'discussion',
|
|
18
|
+
* kind: 'issue' | 'discussion' | 'pull_request' | 'comment',
|
|
19
|
+
* parentKind: 'issue' | 'discussion' | 'pull_request',
|
|
19
20
|
* owner: string,
|
|
20
21
|
* repo: string,
|
|
21
22
|
* number: number,
|
|
22
|
-
* commentId?: number
|
|
23
|
+
* commentId?: number,
|
|
24
|
+
* commentSubKind?: 'issue_comment' | 'review_comment'
|
|
23
25
|
* }}
|
|
24
26
|
*/
|
|
25
27
|
export function parseGitHubUrl(rawUrl) {
|
|
@@ -67,6 +69,20 @@ export function parseGitHubUrl(rawUrl) {
|
|
|
67
69
|
const number = toNumber(numberRaw)
|
|
68
70
|
if (!number) return null
|
|
69
71
|
|
|
72
|
+
const issueCommentMatch = parsed.hash.match(ISSUE_COMMENT_HASH_RE)
|
|
73
|
+
if (issueCommentMatch) {
|
|
74
|
+
const commentId = toNumber(issueCommentMatch[1])
|
|
75
|
+
if (!commentId) return null
|
|
76
|
+
return { kind: 'comment', parentKind: 'pull_request', owner, repo, number, commentId, commentSubKind: 'issue_comment' }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const reviewCommentMatch = parsed.hash.match(PR_REVIEW_COMMENT_HASH_RE)
|
|
80
|
+
if (reviewCommentMatch) {
|
|
81
|
+
const commentId = toNumber(reviewCommentMatch[1])
|
|
82
|
+
if (!commentId) return null
|
|
83
|
+
return { kind: 'comment', parentKind: 'pull_request', owner, repo, number, commentId, commentSubKind: 'review_comment' }
|
|
84
|
+
}
|
|
85
|
+
|
|
70
86
|
if (parsed.hash) return null
|
|
71
87
|
return { kind: 'pull_request', parentKind: 'pull_request', owner, repo, number }
|
|
72
88
|
}
|
|
@@ -54,6 +54,30 @@ describe('parseGitHubUrl', () => {
|
|
|
54
54
|
})
|
|
55
55
|
})
|
|
56
56
|
|
|
57
|
+
it('classifies PR top-level comment URLs', () => {
|
|
58
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/pull/12#issuecomment-555')).toEqual({
|
|
59
|
+
kind: 'comment',
|
|
60
|
+
parentKind: 'pull_request',
|
|
61
|
+
commentSubKind: 'issue_comment',
|
|
62
|
+
owner: 'dfosco',
|
|
63
|
+
repo: 'storyboard',
|
|
64
|
+
number: 12,
|
|
65
|
+
commentId: 555,
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('classifies PR inline review comment URLs', () => {
|
|
70
|
+
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/pull/12#discussion_r777')).toEqual({
|
|
71
|
+
kind: 'comment',
|
|
72
|
+
parentKind: 'pull_request',
|
|
73
|
+
commentSubKind: 'review_comment',
|
|
74
|
+
owner: 'dfosco',
|
|
75
|
+
repo: 'storyboard',
|
|
76
|
+
number: 12,
|
|
77
|
+
commentId: 777,
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
57
81
|
it('rejects unsupported paths and hashes', () => {
|
|
58
82
|
expect(parseGitHubUrl('https://github.com/dfosco/storyboard/issues/12#random')).toBeNull()
|
|
59
83
|
expect(parseGitHubUrl('https://example.com/dfosco/storyboard/issues/12')).toBeNull()
|
|
@@ -68,6 +68,11 @@ function parseDataFile(filePath, opts = {}) {
|
|
|
68
68
|
const folderDirMatch = normalized.match(/(?:^|\/)src\/prototypes\/([^/]+)\.folder\//)
|
|
69
69
|
const folderName = folderDirMatch ? folderDirMatch[1] : null
|
|
70
70
|
|
|
71
|
+
// Strip leading `~` from each path segment when building the public URL,
|
|
72
|
+
// so locally-gitignored canvases (e.g. ~notes.canvas.jsonl) are reachable
|
|
73
|
+
// at the same route as their non-prefixed counterpart. The on-disk `name`
|
|
74
|
+
// and `id` keep the `~` so they remain unique vs a sibling without it.
|
|
75
|
+
const stripTilde = (p) => p.split('/').map(seg => seg.replace(/^~/, '')).join('/')
|
|
71
76
|
const canvasCheck = normalized.match(/(?:^|\/)src\/canvas\//)
|
|
72
77
|
if (canvasCheck) {
|
|
73
78
|
const dirPath = normalized.substring(0, normalized.lastIndexOf('/'))
|
|
@@ -79,7 +84,7 @@ function parseDataFile(filePath, opts = {}) {
|
|
|
79
84
|
.replace(/\/+/g, '/')
|
|
80
85
|
.replace(/\/$/, '')
|
|
81
86
|
name = idBase ? `${idBase}/${baseName}` : baseName
|
|
82
|
-
inferredRoute = '/canvas/' + name
|
|
87
|
+
inferredRoute = '/canvas/' + stripTilde(name)
|
|
83
88
|
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
|
|
84
89
|
}
|
|
85
90
|
const protoCheck = normalized.match(/(?:^|\/)src\/prototypes\//)
|
|
@@ -92,7 +97,7 @@ function parseDataFile(filePath, opts = {}) {
|
|
|
92
97
|
.replace(/\/+/g, '/')
|
|
93
98
|
.replace(/\/$/, '')
|
|
94
99
|
name = idBase ? `${idBase}/${baseName}` : baseName
|
|
95
|
-
inferredRoute = '/canvas/' + name
|
|
100
|
+
inferredRoute = '/canvas/' + stripTilde(name)
|
|
96
101
|
inferredRoute = inferredRoute.replace(/\/+/g, '/').replace(/\/$/, '') || '/canvas'
|
|
97
102
|
}
|
|
98
103
|
// Derive group: canvases sharing a directory form a group
|
|
@@ -1214,10 +1214,17 @@ describe('parseDataFile — canvas path-based IDs', () => {
|
|
|
1214
1214
|
const file = parseDataFile('src/canvas/~scratch.canvas.jsonl', { includeTilde: true })
|
|
1215
1215
|
expect(file).not.toBeNull()
|
|
1216
1216
|
expect(file.name).toBe('~scratch')
|
|
1217
|
-
|
|
1217
|
+
// Public route strips the `~` so locally-gitignored canvases reuse the
|
|
1218
|
+
// same URL as their non-prefixed counterpart.
|
|
1219
|
+
expect(file.inferredRoute).toBe('/canvas/scratch')
|
|
1218
1220
|
const inDir = parseDataFile('src/canvas/~private/notes.canvas.jsonl', { includeTilde: true })
|
|
1219
1221
|
expect(inDir).not.toBeNull()
|
|
1220
1222
|
expect(inDir.name).toBe('~private/notes')
|
|
1223
|
+
expect(inDir.inferredRoute).toBe('/canvas/private/notes')
|
|
1224
|
+
const inSubdir = parseDataFile('src/canvas/dfosco-explorations/~notes.canvas.jsonl', { includeTilde: true })
|
|
1225
|
+
expect(inSubdir).not.toBeNull()
|
|
1226
|
+
expect(inSubdir.name).toBe('dfosco-explorations/~notes')
|
|
1227
|
+
expect(inSubdir.inferredRoute).toBe('/canvas/dfosco-explorations/notes')
|
|
1221
1228
|
})
|
|
1222
1229
|
|
|
1223
1230
|
it('canvas outside known directories gets basename-only ID', () => {
|