Skip to content

Commit ca9151d

Browse files
feat: render viewer source citations
1 parent d01ed64 commit ca9151d

9 files changed

Lines changed: 444 additions & 47 deletions

File tree

src/codealmanac/server/assets/app.css

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,12 +661,51 @@ button {
661661

662662
.wiki-markdown a {
663663
color: var(--accent);
664-
font-weight: 600;
664+
font-family: inherit;
665+
font-weight: inherit;
665666
text-decoration: underline;
666667
text-decoration-color: rgba(152, 118, 26, 0.28);
667668
text-underline-offset: 3px;
668669
}
669670

671+
.wiki-markdown .wiki-citation {
672+
position: relative;
673+
margin-left: 2px;
674+
color: var(--accent);
675+
font-family: var(--sans);
676+
font-size: 0.72em;
677+
font-weight: 800;
678+
line-height: 1;
679+
text-decoration: underline;
680+
text-decoration-color: rgba(152, 118, 26, 0.28);
681+
text-underline-offset: 2px;
682+
vertical-align: super;
683+
}
684+
685+
.wiki-markdown .wiki-citation:hover::after,
686+
.wiki-markdown .wiki-citation:focus-visible::after {
687+
position: absolute;
688+
z-index: 10;
689+
bottom: calc(100% + 8px);
690+
left: 50%;
691+
width: max-content;
692+
max-width: min(22rem, 72vw);
693+
transform: translateX(-50%);
694+
border: 1px solid var(--border);
695+
border-radius: 6px;
696+
background: var(--surface);
697+
box-shadow: 0 10px 24px rgba(42, 46, 28, 0.14);
698+
color: var(--ink);
699+
content: attr(data-citation-tooltip);
700+
font-family: var(--sans);
701+
font-size: 11.5px;
702+
font-weight: 500;
703+
line-height: 1.35;
704+
padding: 8px 9px;
705+
text-align: left;
706+
white-space: normal;
707+
}
708+
670709
.wiki-markdown code {
671710
border-radius: 5px;
672711
background: rgba(42, 46, 28, 0.07);
@@ -753,6 +792,53 @@ button {
753792
color: var(--accent);
754793
}
755794

795+
.wiki-source-item {
796+
display: grid;
797+
grid-template-columns: 22px minmax(0, 1fr);
798+
gap: 9px;
799+
align-items: start;
800+
color: var(--ink);
801+
white-space: normal;
802+
}
803+
804+
.wiki-source-number {
805+
display: inline-grid;
806+
place-items: center;
807+
width: 20px;
808+
height: 20px;
809+
border-radius: 999px;
810+
background: var(--cream);
811+
color: var(--accent);
812+
font-size: 11px;
813+
font-weight: 800;
814+
line-height: 1;
815+
}
816+
817+
.wiki-source-body {
818+
display: grid;
819+
gap: 3px;
820+
min-width: 0;
821+
}
822+
823+
.wiki-source-body strong {
824+
overflow: hidden;
825+
color: var(--ink);
826+
font-size: 12.5px;
827+
text-overflow: ellipsis;
828+
white-space: nowrap;
829+
}
830+
831+
.wiki-source-body span {
832+
display: -webkit-box;
833+
overflow: hidden;
834+
color: var(--muted);
835+
font-size: 11.5px;
836+
line-height: 1.35;
837+
white-space: normal;
838+
-webkit-box-orient: vertical;
839+
-webkit-line-clamp: 3;
840+
}
841+
756842
.job-list,
757843
.job-events,
758844
.job-detail {

src/codealmanac/server/assets/viewer/components.js

Lines changed: 82 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,11 @@ export function pageTitleBlock(page) {
5656
return header;
5757
}
5858

59-
export function markdown(html) {
59+
export function markdown(html, sources = []) {
6060
const article = document.createElement("article");
6161
article.className = "wiki-markdown";
6262
article.innerHTML = html;
63+
attachCitationHints(article, sources);
6364
return article;
6465
}
6566

@@ -124,20 +125,91 @@ export function fileSideLink(path) {
124125
}
125126

126127
export function sourceSideItem(source) {
127-
const label = sourceLabel(source);
128-
if (source.source_type === "file" && source.target) {
129-
return sideLink(fileHref(source.target), label);
130-
}
128+
const hasTarget = Boolean(source.target);
129+
const item = document.createElement(hasTarget ? "a" : "span");
130+
item.className = "wiki-source-item";
131+
item.id = source.source_id ? `source-${encodeURIComponent(source.source_id)}` : "";
132+
item.dataset.sourceId = source.source_id || "";
131133
if (source.source_type === "web" && source.target) {
132-
const link = sideLink(source.target, label);
133-
link.rel = "noreferrer";
134-
return link;
134+
item.href = source.target;
135+
item.rel = "noreferrer";
136+
} else if (source.source_type === "file" && source.target) {
137+
item.href = fileHref(source.target);
135138
}
136-
const item = document.createElement("span");
137-
item.textContent = label;
139+
140+
const number = document.createElement("span");
141+
number.className = "wiki-source-number";
142+
number.textContent =
143+
source.citation_number === null || source.citation_number === undefined
144+
? "–"
145+
: String(source.citation_number);
146+
147+
const body = document.createElement("span");
148+
body.className = "wiki-source-body";
149+
const title = document.createElement("strong");
150+
title.textContent = sourceTitle(source);
151+
const detail = document.createElement("span");
152+
detail.textContent = sourceDetail(source);
153+
body.append(title, detail);
154+
155+
item.append(number, body);
138156
return item;
139157
}
140158

159+
function attachCitationHints(article, sources) {
160+
const sourcesById = new Map(
161+
sources
162+
.filter((source) => source.source_id)
163+
.map((source) => [source.source_id, source]),
164+
);
165+
for (const citation of article.querySelectorAll(".wiki-citation")) {
166+
const source = sourcesById.get(citation.dataset.sourceId || "");
167+
if (!source) continue;
168+
const label = citationTooltip(source);
169+
citation.title = label;
170+
citation.setAttribute("aria-label", label);
171+
citation.dataset.citationTooltip = label;
172+
}
173+
}
174+
175+
function citationTooltip(source) {
176+
const number =
177+
source.citation_number === null || source.citation_number === undefined
178+
? "Source"
179+
: `Source ${source.citation_number}`;
180+
const title = sourceTitle(source);
181+
const detail = sourceDetail(source);
182+
return detail && detail !== title ? `${number}: ${title}${detail}` : `${number}: ${title}`;
183+
}
184+
185+
function sourceTitle(source) {
186+
if (source.title) {
187+
return source.title;
188+
}
189+
if (source.note) {
190+
return source.note;
191+
}
192+
return humanizeSourceId(source.source_id || "untitled-source");
193+
}
194+
195+
function sourceDetail(source) {
196+
if (source.title && source.note) {
197+
return source.note;
198+
}
199+
if (source.target) {
200+
return source.target;
201+
}
202+
return source.source_type;
203+
}
204+
205+
function humanizeSourceId(sourceId) {
206+
return sourceId
207+
.split(/[-_]/)
208+
.filter(Boolean)
209+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
210+
.join(" ");
211+
}
212+
141213
export function emptyState(title, body) {
142214
const box = document.createElement("section");
143215
box.className = "app-empty";
@@ -210,8 +282,3 @@ function sidebarEmpty() {
210282
empty.textContent = "None";
211283
return empty;
212284
}
213-
214-
function sourceLabel(source) {
215-
const target = source.target ? ` ${source.target}` : "";
216-
return `${source.source_id} [${source.source_type}]${target}`;
217-
}

src/codealmanac/server/assets/viewer/main.js

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ const state = {
2222
selectedWiki: "",
2323
};
2424

25+
const FOLDER_TITLE_WORDS = new Map([
26+
["ai", "AI"],
27+
["rls", "RLS"],
28+
]);
29+
30+
const FOLDER_SORT_PRIORITY = new Map([["start-here", -1]]);
31+
2532
export function startViewer() {
2633
const elements = readElements();
2734
elements.searchForm.addEventListener("submit", (event) => {
@@ -126,16 +133,117 @@ function renderNav(elements, overview) {
126133
),
127134
);
128135
elements.pageList.replaceChildren(
129-
...overview.pages.map((page) =>
130-
navLink(pageHref(page.slug), page.title || page.slug, {
131-
kind: RouteKind.PAGE,
132-
value: page.slug,
133-
title: page.summary || `${page.slug}.md`,
134-
}),
135-
),
136+
...pageTree(overview.navigation_pages || overview.pages),
136137
);
137138
}
138139

140+
function pageTree(pages) {
141+
const root = createFolderNode("");
142+
for (const page of pages) {
143+
insertPage(root, page);
144+
}
145+
return renderFolderChildren(root, 0);
146+
}
147+
148+
function createFolderNode(name) {
149+
return {
150+
name,
151+
folders: new Map(),
152+
pages: [],
153+
};
154+
}
155+
156+
function insertPage(root, page) {
157+
const path = page.path || `${page.slug}.md`;
158+
const parts = path.split("/").filter(Boolean);
159+
const filename = parts.pop() || `${page.slug}.md`;
160+
let cursor = root;
161+
for (const part of parts) {
162+
if (!cursor.folders.has(part)) {
163+
cursor.folders.set(part, createFolderNode(part));
164+
}
165+
cursor = cursor.folders.get(part);
166+
}
167+
cursor.pages.push({ ...page, filename });
168+
}
169+
170+
function renderFolderChildren(folder, depth) {
171+
const children = [];
172+
for (const child of sortedFolders(folder)) {
173+
children.push(renderFolder(child, depth));
174+
}
175+
for (const page of sortedPages(folder.pages)) {
176+
children.push(renderPageLink(page, depth));
177+
}
178+
return children;
179+
}
180+
181+
function renderFolder(folder, depth) {
182+
const details = document.createElement("details");
183+
details.className = "wiki-rail-folder";
184+
185+
const summary = document.createElement("summary");
186+
summary.className = "wiki-rail-folder-summary";
187+
setRailIndent(summary, depth);
188+
summary.textContent = folderTitle(folder.name);
189+
190+
const children = document.createElement("div");
191+
children.className = "wiki-rail-folder-children";
192+
children.append(...renderFolderChildren(folder, depth + 1));
193+
194+
details.append(summary, children);
195+
return details;
196+
}
197+
198+
function renderPageLink(page, depth) {
199+
const link = navLink(pageHref(page.slug), page.title || page.slug, {
200+
kind: RouteKind.PAGE,
201+
value: page.slug,
202+
title: page.summary || page.path || `${page.slug}.md`,
203+
});
204+
link.classList.add("wiki-rail-page");
205+
setRailIndent(link, depth);
206+
return link;
207+
}
208+
209+
function setRailIndent(element, depth) {
210+
element.style.setProperty("--rail-indent", `${depth * 14}px`);
211+
}
212+
213+
function sortedFolders(folder) {
214+
return Array.from(folder.folders.values()).sort(compareFolders);
215+
}
216+
217+
function sortedPages(pages) {
218+
return [...pages].sort((a, b) => {
219+
const aTitle = a.title || a.filename || a.slug;
220+
const bTitle = b.title || b.filename || b.slug;
221+
return aTitle.localeCompare(bTitle);
222+
});
223+
}
224+
225+
function folderTitle(name) {
226+
return name
227+
.split(/[-_]/)
228+
.filter(Boolean)
229+
.map((part) => FOLDER_TITLE_WORDS.get(part) || titleWord(part))
230+
.join(" ");
231+
}
232+
233+
function compareFolders(a, b) {
234+
const priority = folderPriority(a.name) - folderPriority(b.name);
235+
if (priority !== 0) return priority;
236+
return folderTitle(a.name).localeCompare(folderTitle(b.name));
237+
}
238+
239+
function folderPriority(name) {
240+
return FOLDER_SORT_PRIORITY.get(name) || 0;
241+
}
242+
243+
function titleWord(value) {
244+
return value.charAt(0).toUpperCase() + value.slice(1);
245+
}
246+
139247
function setActiveNav(elements, routeState) {
140248
const active = activeNavKind(routeState);
141249
for (const item of elements.navItems) {

src/codealmanac/server/assets/viewer/renderers.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export async function renderPage(context, slug) {
3939

4040
const article = document.createElement("article");
4141
article.className = "wiki-page-card";
42-
article.append(pageTitleBlock(page), markdown(page.html));
42+
article.append(pageTitleBlock(page), markdown(page.html, page.sources));
4343

4444
const sidePanel = document.createElement("aside");
4545
sidePanel.className = "wiki-side-panel";

src/codealmanac/services/viewer/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ class ViewerPageSource(CodeAlmanacModel):
4242
title: str | None
4343
retrieved_at: str | None
4444
note: str | None
45+
citation_number: int | None
4546

4647

4748
class ViewerOverview(CodeAlmanacModel):

0 commit comments

Comments
 (0)