mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Update sources styling (#1838)
This commit is contained in:
parent
feeea21127
commit
047d42c5a7
3 changed files with 229 additions and 81 deletions
|
|
@ -96,7 +96,8 @@ More content
|
|||
const dashHeading = "Content\nSources -\n[^1]: [[Doc]]";
|
||||
expect(hasExistingCitations(dashHeading)).toBe(true);
|
||||
|
||||
const summary = "<details><summary>Sources</summary>List</details>";
|
||||
const summary =
|
||||
'<details><summary class="copilot-sources__summary">Sources</summary>List</details>';
|
||||
expect(hasExistingCitations(summary)).toBe(true);
|
||||
});
|
||||
|
||||
|
|
@ -188,9 +189,10 @@ More content
|
|||
[^2]: [[Document 2]]`;
|
||||
|
||||
const result = processInlineCitations(content, true);
|
||||
expect(result).toContain("Sources</summary>");
|
||||
expect(result).toContain("Document 1");
|
||||
expect(result).toContain("Document 2");
|
||||
expect(result).toContain("copilot-sources__summary");
|
||||
expect(result).toContain('copilot-sources__index">[1]');
|
||||
expect(result).toContain('copilot-sources__text">[[Document 1]]');
|
||||
expect(result).toContain('copilot-sources__text">[[Document 2]]');
|
||||
});
|
||||
|
||||
it("should use simple expandable list when disabled", () => {
|
||||
|
|
@ -201,9 +203,10 @@ More content
|
|||
- [[Document 2]]`;
|
||||
|
||||
const result = processInlineCitations(content, false);
|
||||
expect(result).toContain("Sources</summary>");
|
||||
expect(result).toContain("Document 1");
|
||||
expect(result).toContain("Document 2");
|
||||
expect(result).toContain("copilot-sources__summary");
|
||||
expect(result).toContain('copilot-sources__index">[1]');
|
||||
expect(result).toContain('copilot-sources__text">[[Document 1]]');
|
||||
expect(result).toContain('copilot-sources__text">[[Document 2]]');
|
||||
});
|
||||
|
||||
it("should return unchanged content when no sources section", () => {
|
||||
|
|
@ -350,10 +353,18 @@ More content
|
|||
// [^2] (third mention) -> [3] -> should map to [[2024-03-26]]
|
||||
// [^18] (fourth mention) -> [4] -> should map to [[2024-04-08]]
|
||||
|
||||
expect(result).toContain("<strong>[1]</strong> [[2024-03-13]]"); // [^9] -> [1]
|
||||
expect(result).toContain("<strong>[2]</strong> [[2024-03-18]]"); // [^1] -> [2]
|
||||
expect(result).toContain("<strong>[3]</strong> [[2024-03-26]]"); // [^2] -> [3]
|
||||
expect(result).toContain("<strong>[4]</strong> [[2024-04-08]]"); // [^18] -> [4]
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[1]</span><span class="copilot-sources__text">[[2024-03-13]]</span>'
|
||||
);
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[2]</span><span class="copilot-sources__text">[[2024-03-18]]</span>'
|
||||
);
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[3]</span><span class="copilot-sources__text">[[2024-03-26]]</span>'
|
||||
);
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[4]</span><span class="copilot-sources__text">[[2024-04-08]]</span>'
|
||||
);
|
||||
|
||||
// Verify the citations in text are renumbered correctly
|
||||
expect(result).toContain("Dolce Arts Studio [1]"); // [^9] -> [1]
|
||||
|
|
@ -380,10 +391,14 @@ More content
|
|||
const result = processInlineCitations(content, true);
|
||||
|
||||
// Should consolidate the 3 "How to Make Wealth" entries into 1
|
||||
expect(result).toContain("<strong>[1]</strong> [[How to Make Wealth]]");
|
||||
expect(result).toContain("<strong>[2]</strong> [[Superlinear Returns]]");
|
||||
expect(result).not.toContain("<strong>[3]</strong>");
|
||||
expect(result).not.toContain("<strong>[4]</strong>");
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[1]</span><span class="copilot-sources__text">[[How to Make Wealth]]</span>'
|
||||
);
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[2]</span><span class="copilot-sources__text">[[Superlinear Returns]]</span>'
|
||||
);
|
||||
expect(result).not.toContain('copilot-sources__index">[3]');
|
||||
expect(result).not.toContain('copilot-sources__index">[4]');
|
||||
|
||||
// Verify periods after citations are removed
|
||||
expect(result).not.toContain("[1].");
|
||||
|
|
@ -419,10 +434,16 @@ More content
|
|||
// [^22] (fourth mention) -> [4] -> [[Document B]] (should consolidate with [^14])
|
||||
|
||||
// After consolidation, should only have 3 unique sources:
|
||||
expect(result).toContain("<strong>[1]</strong> [[Document B]]"); // [^14] and [^22] consolidated
|
||||
expect(result).toContain("<strong>[2]</strong> [[Document C]]"); // [^17]
|
||||
expect(result).toContain("<strong>[3]</strong> [[Document A]]"); // [^3]
|
||||
expect(result).not.toContain("<strong>[4]</strong>");
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[1]</span><span class="copilot-sources__text">[[Document B]]</span>'
|
||||
);
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[2]</span><span class="copilot-sources__text">[[Document C]]</span>'
|
||||
);
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[3]</span><span class="copilot-sources__text">[[Document A]]</span>'
|
||||
);
|
||||
expect(result).not.toContain('copilot-sources__index">[4]');
|
||||
|
||||
// Verify citations in text point to correct consolidated sources
|
||||
expect(result).toContain("references [1] and also [2]"); // [^14]->[1], [^17]->[2]
|
||||
|
|
@ -447,8 +468,12 @@ More content
|
|||
// [^7] (first mention) -> [1] -> [[Superlinear Returns]]
|
||||
// [^8] (second mention) -> [2] -> [[How to Do Great Work]]
|
||||
|
||||
expect(result).toContain("<strong>[1]</strong> [[Superlinear Returns]]"); // [^7] -> [1]
|
||||
expect(result).toContain("<strong>[2]</strong> [[How to Do Great Work]]"); // [^8] -> [2]
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[1]</span><span class="copilot-sources__text">[[Superlinear Returns]]</span>'
|
||||
);
|
||||
expect(result).toContain(
|
||||
'<span class="copilot-sources__index">[2]</span><span class="copilot-sources__text">[[How to Do Great Work]]</span>'
|
||||
);
|
||||
|
||||
// CRITICAL: Both citations in text must be converted (not partial like [4][^8])
|
||||
expect(result).toContain("thresholds). [1][2]"); // [^7][^8] -> [1][2]
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ export function hasExistingCitations(response: string): boolean {
|
|||
const content = response || "";
|
||||
const hasMarkdownHeading = /(^|\n)\s*#{1,6}\s*Sources\b/i.test(content);
|
||||
const hasPlainLabel = /(^|\n)\s*Sources\s*(?:[:-]\s*)?(\n|$)/i.test(content);
|
||||
const hasSummaryTag = /<summary>\s*Sources\s*<\/summary>/i.test(content);
|
||||
const hasSummaryTag = /<summary[^>]*>\s*Sources\s*<\/summary>/i.test(content);
|
||||
// More robust detection: look for ANY line starting with [^digits]:
|
||||
const hasFootnoteDefinitions = /(^|\n)\s*\[\^\d+\]:\s*/.test(content);
|
||||
return hasMarkdownHeading || hasPlainLabel || hasSummaryTag || hasFootnoteDefinitions;
|
||||
|
|
@ -403,6 +403,93 @@ export function updateCitationsForConsolidation(
|
|||
});
|
||||
}
|
||||
|
||||
interface SourcesDisplayItem {
|
||||
index: number;
|
||||
html: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes HTML-sensitive characters to avoid unintended markup injection.
|
||||
*/
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard that filters out nullish values when mapping optional entries.
|
||||
*/
|
||||
function isNonNull<T>(value: T | null | undefined): value is T {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a normalized sources block into display-ready list items.
|
||||
*/
|
||||
function parseSimpleSources(sourcesBlock: string): SourcesDisplayItem[] {
|
||||
const lines = sourcesBlock.split("\n");
|
||||
const items: SourcesDisplayItem[] = [];
|
||||
|
||||
lines.forEach((rawLine) => {
|
||||
const trimmed = rawLine.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const content = trimmed
|
||||
.replace(/^<li>/i, "")
|
||||
.replace(/<\/li>$/i, "")
|
||||
.replace(/^[-*]\s*/, "")
|
||||
.trim();
|
||||
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markdownLink = content.match(/\[([^\]]+)\]\(([^)]+)\)/);
|
||||
const wikiLink = content.match(/\[\[(.*?)\]\]/);
|
||||
|
||||
let html: string;
|
||||
if (markdownLink) {
|
||||
html = `<a href="${markdownLink[2]}">${markdownLink[1]}</a>`;
|
||||
} else if (wikiLink) {
|
||||
html = `[[${wikiLink[1]}]]`;
|
||||
} else {
|
||||
html = escapeHtml(content);
|
||||
}
|
||||
|
||||
items.push({ index: items.length + 1, html });
|
||||
});
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a styled, collapsible sources list to the main message content.
|
||||
*/
|
||||
function buildSourcesDetails(mainContent: string, items: SourcesDisplayItem[]): string {
|
||||
if (items.length === 0) {
|
||||
return mainContent;
|
||||
}
|
||||
|
||||
const listItems = items
|
||||
.map(
|
||||
({ index, html }) =>
|
||||
`<li class="copilot-sources__item"><span class="copilot-sources__index">[${index}]</span><span class="copilot-sources__text">${html}</span></li>`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
`${mainContent}\n\n<br/>\n` +
|
||||
`<details class="copilot-sources"><summary class="copilot-sources__summary">Sources</summary>\n` +
|
||||
`<ul class="copilot-sources__list">\n${listItems}\n</ul>\n</details>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to process inline citations in content.
|
||||
* Uses settings to determine if inline citations should be processed.
|
||||
|
|
@ -416,46 +503,15 @@ export function processInlineCitations(content: string, useInlineCitations: bool
|
|||
|
||||
// If inline citations are disabled, use simple expandable sources list
|
||||
if (!useInlineCitations) {
|
||||
const sourceLinks = sourcesBlock
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const match = line.match(/- \[\[(.*?)\]\]/);
|
||||
if (match) {
|
||||
return `<li>[[${match[1]}]]</li>`;
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
mainContent +
|
||||
"\n\n<br/>\n<details><summary>Sources</summary>\n<ul>\n" +
|
||||
sourceLinks +
|
||||
"\n</ul>\n</details>"
|
||||
);
|
||||
const simpleItems = parseSimpleSources(sourcesBlock);
|
||||
return buildSourcesDetails(mainContent, simpleItems);
|
||||
}
|
||||
|
||||
// Process inline citations
|
||||
const footnoteLines = parseFootnoteDefinitions(sourcesBlock);
|
||||
if (footnoteLines.length === 0) {
|
||||
// Not footnote format, use simple sources list
|
||||
const sourceLinks = sourcesBlock
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
const match = line.match(/- \[\[(.*?)\]\]/);
|
||||
if (match) {
|
||||
return `<li>[[${match[1]}]]</li>`;
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
mainContent +
|
||||
"\n\n<br/>\n<details><summary>Sources</summary>\n<ul>\n" +
|
||||
sourceLinks +
|
||||
"\n</ul>\n</details>"
|
||||
);
|
||||
const simpleItems = parseSimpleSources(sourcesBlock);
|
||||
return buildSourcesDetails(mainContent, simpleItems);
|
||||
}
|
||||
|
||||
// Process footnote-style citations
|
||||
|
|
@ -471,31 +527,16 @@ export function processInlineCitations(content: string, useInlineCitations: bool
|
|||
items = uniqueItems;
|
||||
}
|
||||
|
||||
// Build sources list maintaining proper citation mapping
|
||||
// The old code used .filter().map() which destroyed the mapping by renumbering
|
||||
// Now we build the list respecting the citation numbers used in the text
|
||||
const maxCitationNum = Math.max(
|
||||
...Array.from(citationMap.values()).concat(
|
||||
consolidationMap.size > 0 ? Array.from(consolidationMap.values()) : []
|
||||
)
|
||||
);
|
||||
const detailedItems = items
|
||||
.map<SourcesDisplayItem | null>((item, index) => {
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
return { index: index + 1, html: item };
|
||||
})
|
||||
.filter(isNonNull);
|
||||
|
||||
const sourcesList: string[] = [];
|
||||
for (let i = 1; i <= maxCitationNum; i++) {
|
||||
const item = items[i - 1];
|
||||
if (item) {
|
||||
sourcesList.push(`<li><strong>[${i}]</strong> ${item}</li>`);
|
||||
}
|
||||
}
|
||||
|
||||
const formattedSourcesList = sourcesList.join("\n");
|
||||
|
||||
return (
|
||||
mainContent +
|
||||
"\n\n<br/>\n<details><summary>Sources</summary>\n<ul>\n" +
|
||||
formattedSourcesList +
|
||||
"\n</ul>\n</details>"
|
||||
);
|
||||
return buildSourcesDetails(mainContent, detailedItems);
|
||||
}
|
||||
|
||||
// ===== SOURCE CATALOG UTILITIES =====
|
||||
|
|
|
|||
|
|
@ -881,3 +881,85 @@ If your plugin does not need CSS, delete this file.
|
|||
.workspace-leaf-content[data-type="copilot-chat-view"] .view-content {
|
||||
padding-bottom: max(var(--safe-area-inset-bottom), var(--size-4-8)) !important;
|
||||
}
|
||||
|
||||
/* Collapsible sources styling */
|
||||
.copilot-sources {
|
||||
margin-top: var(--size-4-1);
|
||||
padding: calc(var(--size-4-2) / 2) var(--size-4-2);
|
||||
border-radius: var(--radius-s);
|
||||
border: 1px solid color-mix(in srgb, var(--background-modifier-border) 70%, transparent);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--background-primary) 94%,
|
||||
var(--background-modifier-border) 6%
|
||||
);
|
||||
width: min(100%, 32rem);
|
||||
}
|
||||
|
||||
.copilot-sources__summary {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: color-mix(in srgb, var(--text-muted) 90%, transparent);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--size-4-2);
|
||||
padding: calc(var(--size-4-2) / 4) 0;
|
||||
padding-left: var(--size-4-2);
|
||||
padding-right: var(--size-4-2);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copilot-sources__summary::-webkit-details-marker {
|
||||
color: color-mix(in srgb, var(--text-muted) 80%, transparent);
|
||||
}
|
||||
|
||||
.copilot-sources__summary::after {
|
||||
content: ">";
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: color-mix(in srgb, var(--text-muted) 70%, transparent);
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.copilot-sources[open] .copilot-sources__summary {
|
||||
margin-bottom: calc(var(--size-4-2) / 2);
|
||||
}
|
||||
|
||||
.copilot-sources[open] .copilot-sources__summary::after {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.copilot-sources__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
padding-left: var(--size-4-2);
|
||||
padding-right: var(--size-4-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: calc(var(--size-4-2) / 2);
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: color-mix(in srgb, var(--text-muted) 85%, transparent);
|
||||
}
|
||||
|
||||
.copilot-sources__item {
|
||||
display: flex;
|
||||
gap: var(--size-4-2);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.copilot-sources__index {
|
||||
color: color-mix(in srgb, var(--text-muted) 65%, transparent);
|
||||
font-weight: var(--font-medium);
|
||||
min-width: var(--size-4-3);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.copilot-sources__text {
|
||||
color: color-mix(in srgb, var(--text-muted) 90%, transparent);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.copilot-sources__text a {
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue