mirror of
https://github.com/ollama/ollama.git
synced 2025-11-10 21:07:45 +01:00
ui: using streamdown AI elements for markdown rendering
This commit is contained in:
1512
app/ui/app/package-lock.json
generated
1512
app/ui/app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,7 @@
|
|||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-math": "^6.0.0",
|
"remark-math": "^6.0.0",
|
||||||
|
"streamdown": "^1.4.0",
|
||||||
"unist-builder": "^4.0.0",
|
"unist-builder": "^4.0.0",
|
||||||
"unist-util-parents": "^3.0.0"
|
"unist-util-parents": "^3.0.0"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,522 +0,0 @@
|
|||||||
import { expect, test, suite } from "vitest";
|
|
||||||
import { processStreamingMarkdown } from "@/utils/processStreamingMarkdown";
|
|
||||||
|
|
||||||
suite("common llm outputs that cause issues", () => {
|
|
||||||
test("prefix of bolded list item shouldn't make a horizontal line", () => {
|
|
||||||
// we're going to go in order of incrementally adding characters. This
|
|
||||||
// happens really commonly with LLMs that like to make lists like so:
|
|
||||||
//
|
|
||||||
// * **point 1**: explanatory text
|
|
||||||
// * **point 2**: more explanatory text
|
|
||||||
//
|
|
||||||
// Partial rendering of `*` (A), followed by `* *` (B), followed by `* **`
|
|
||||||
// (C) is a total mess. (A) renders as a single bullet point in an
|
|
||||||
// otherwise empty list, (B) renders as two nested lists (and therefore
|
|
||||||
// two bullet points, styled differently by default in html), and (C)
|
|
||||||
// renders as a horizontal line because in markdown apparently `***` or `*
|
|
||||||
// * *` horizontal rules don't have as strict whitespace rules as I
|
|
||||||
// expected them to
|
|
||||||
|
|
||||||
// these are alone (i.e., they would be the first list item)
|
|
||||||
expect(processStreamingMarkdown("*")).toBe("");
|
|
||||||
expect(processStreamingMarkdown("* *")).toBe("");
|
|
||||||
expect(processStreamingMarkdown("* **")).toBe("");
|
|
||||||
// expect(processStreamingMarkdown("* **b")).toBe("* **b**");
|
|
||||||
|
|
||||||
// with a list item before them
|
|
||||||
expect(
|
|
||||||
processStreamingMarkdown(
|
|
||||||
// prettier-ignore
|
|
||||||
[
|
|
||||||
"* abc",
|
|
||||||
"*"
|
|
||||||
].join("\n"),
|
|
||||||
),
|
|
||||||
).toBe("* abc");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
processStreamingMarkdown(
|
|
||||||
// prettier-ignore
|
|
||||||
[
|
|
||||||
"* abc",
|
|
||||||
"* *"
|
|
||||||
].join("\n"),
|
|
||||||
),
|
|
||||||
).toBe("* abc");
|
|
||||||
|
|
||||||
expect(
|
|
||||||
processStreamingMarkdown(
|
|
||||||
// prettier-ignore
|
|
||||||
[
|
|
||||||
"* abc",
|
|
||||||
"* **"
|
|
||||||
].join("\n"),
|
|
||||||
),
|
|
||||||
).toBe("* abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bolded list items with text should be rendered properly", () => {
|
|
||||||
expect(processStreamingMarkdown("* **abc**")).toBe("* **abc**");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("partially bolded list items should be autoclosed", () => {
|
|
||||||
expect(processStreamingMarkdown("* **abc")).toBe("* **abc**");
|
|
||||||
});
|
|
||||||
|
|
||||||
suite(
|
|
||||||
"partially bolded list items should be autoclosed, even if the last node isn't a text node",
|
|
||||||
() => {
|
|
||||||
test("inline code", () => {
|
|
||||||
expect(
|
|
||||||
processStreamingMarkdown("* **Asynchronous Function `async`*"),
|
|
||||||
).toBe("* **Asynchronous Function `async`**");
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
suite("autoclosing bold", () => {
|
|
||||||
suite("endings with no asterisks", () => {
|
|
||||||
test("should autoclose bold", () => {
|
|
||||||
expect(processStreamingMarkdown("**abc")).toBe("**abc**");
|
|
||||||
expect(processStreamingMarkdown("abc **abc")).toBe("abc **abc**");
|
|
||||||
});
|
|
||||||
|
|
||||||
suite("should autoclose, even if the last node isn't a text node", () => {
|
|
||||||
test("inline code", () => {
|
|
||||||
expect(
|
|
||||||
processStreamingMarkdown("* **Asynchronous Function `async`"),
|
|
||||||
).toBe("* **Asynchronous Function `async`**");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opening ** is at the end of the text", () => {
|
|
||||||
expect(processStreamingMarkdown("abc **`def` jhk [lmn](opq)")).toBe(
|
|
||||||
"abc **`def` jhk [lmn](opq)**",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("if there's a space after the **, it should NOT be autoclosed", () => {
|
|
||||||
expect(processStreamingMarkdown("abc ** `def` jhk [lmn](opq)")).toBe(
|
|
||||||
"abc \\*\\* `def` jhk [lmn](opq)",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should autoclose bold, even if the last node isn't a text node", () => {
|
|
||||||
expect(
|
|
||||||
processStreamingMarkdown("* **Asynchronous Function ( `async`"),
|
|
||||||
).toBe("* **Asynchronous Function ( `async`**");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("whitespace fakeouts should not be modified", () => {
|
|
||||||
expect(processStreamingMarkdown("** abc")).toBe("\\*\\* abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO(drifkin): arguably this should just be removed entirely, but empty
|
|
||||||
// isn't so bad
|
|
||||||
test("should handle empty bolded items", () => {
|
|
||||||
expect(processStreamingMarkdown("**")).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
suite("partially closed bolded items", () => {
|
|
||||||
test("simple partial", () => {
|
|
||||||
expect(processStreamingMarkdown("**abc*")).toBe("**abc**");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("partial with non-text node at end", () => {
|
|
||||||
expect(processStreamingMarkdown("**abc`def`*")).toBe("**abc`def`**");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("partial with multiply nested ending nodes", () => {
|
|
||||||
expect(processStreamingMarkdown("**abc[abc](`def`)*")).toBe(
|
|
||||||
"**abc[abc](`def`)**",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normal emphasis should not be affected", () => {
|
|
||||||
expect(processStreamingMarkdown("*abc*")).toBe("*abc*");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("normal emphasis with nested code should not be affected", () => {
|
|
||||||
expect(processStreamingMarkdown("*`abc`*")).toBe("*`abc`*");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.skip("shouldn't autoclose immediately if there's a space before the closing *", () => {
|
|
||||||
expect(processStreamingMarkdown("**abc *")).toBe("**abc**");
|
|
||||||
});
|
|
||||||
|
|
||||||
// skipping for now because this requires partial link completion as well
|
|
||||||
suite.skip("nested blocks that each need autoclosing", () => {
|
|
||||||
test("emph nested in link nested in strong nested in list item", () => {
|
|
||||||
expect(processStreamingMarkdown("* **[abc **def")).toBe(
|
|
||||||
"* **[abc **def**]()**",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("* **[ab *`def`", () => {
|
|
||||||
expect(processStreamingMarkdown("* **[ab *`def`")).toBe(
|
|
||||||
"* **[ab *`def`*]()**",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
suite("numbered list items", () => {
|
|
||||||
test("should remove trailing numbers", () => {
|
|
||||||
expect(processStreamingMarkdown("1. First\n2")).toBe("1. First");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should remove trailing numbers with breaks before", () => {
|
|
||||||
expect(processStreamingMarkdown("1. First \n2")).toBe("1. First");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("should remove trailing numbers that form a new paragraph", () => {
|
|
||||||
expect(processStreamingMarkdown("1. First\n\n2")).toBe("1. First");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("but should leave list items separated by two newlines", () => {
|
|
||||||
expect(processStreamingMarkdown("1. First\n\n2. S")).toBe(
|
|
||||||
"1. First\n\n2. S",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// TODO(drifkin):slop tests ahead, some are decent, but need to manually go
|
|
||||||
// through them as I implement
|
|
||||||
/*
|
|
||||||
describe("StreamingMarkdownContent - processStreamingMarkdown", () => {
|
|
||||||
describe("Ambiguous endings removal", () => {
|
|
||||||
it("should remove list markers at the end", () => {
|
|
||||||
expect(processStreamingMarkdown("Some text\n* ")).toBe("Some text");
|
|
||||||
expect(processStreamingMarkdown("Some text\n*")).toBe("Some text");
|
|
||||||
expect(processStreamingMarkdown("* Item 1\n- ")).toBe("* Item 1");
|
|
||||||
expect(processStreamingMarkdown("* Item 1\n-")).toBe("* Item 1");
|
|
||||||
expect(processStreamingMarkdown("Text\n+ ")).toBe("Text");
|
|
||||||
expect(processStreamingMarkdown("Text\n+")).toBe("Text");
|
|
||||||
expect(processStreamingMarkdown("1. First\n2. ")).toBe("1. First");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove heading markers at the end", () => {
|
|
||||||
expect(processStreamingMarkdown("Some text\n# ")).toBe("Some text");
|
|
||||||
expect(processStreamingMarkdown("Some text\n#")).toBe("Some text\n#"); // # without space is not removed
|
|
||||||
expect(processStreamingMarkdown("# Title\n## ")).toBe("# Title");
|
|
||||||
expect(processStreamingMarkdown("# Title\n##")).toBe("# Title\n##"); // ## without space is not removed
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove ambiguous bold markers at the end", () => {
|
|
||||||
expect(processStreamingMarkdown("Text **")).toBe("Text ");
|
|
||||||
expect(processStreamingMarkdown("Some text\n**")).toBe("Some text");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove code block markers at the end", () => {
|
|
||||||
expect(processStreamingMarkdown("Text\n```")).toBe("Text");
|
|
||||||
expect(processStreamingMarkdown("```")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove single backtick at the end", () => {
|
|
||||||
expect(processStreamingMarkdown("Text `")).toBe("Text ");
|
|
||||||
expect(processStreamingMarkdown("`")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove single asterisk at the end", () => {
|
|
||||||
expect(processStreamingMarkdown("Text *")).toBe("Text ");
|
|
||||||
expect(processStreamingMarkdown("*")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle empty content", () => {
|
|
||||||
expect(processStreamingMarkdown("")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle single line removals correctly", () => {
|
|
||||||
expect(processStreamingMarkdown("* ")).toBe("");
|
|
||||||
expect(processStreamingMarkdown("# ")).toBe("");
|
|
||||||
expect(processStreamingMarkdown("**")).toBe("");
|
|
||||||
expect(processStreamingMarkdown("`")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("shouldn't have this regexp capture group bug", () => {
|
|
||||||
expect(
|
|
||||||
processStreamingMarkdown("Here's a shopping list:\n*"),
|
|
||||||
).not.toContain("0*");
|
|
||||||
expect(processStreamingMarkdown("Here's a shopping list:\n*")).toBe(
|
|
||||||
"Here's a shopping list:",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("List markers", () => {
|
|
||||||
it("should preserve complete list items", () => {
|
|
||||||
expect(processStreamingMarkdown("* Complete item")).toBe(
|
|
||||||
"* Complete item",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("- Another item")).toBe("- Another item");
|
|
||||||
expect(processStreamingMarkdown("+ Plus item")).toBe("+ Plus item");
|
|
||||||
expect(processStreamingMarkdown("1. Numbered item")).toBe(
|
|
||||||
"1. Numbered item",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle indented list markers", () => {
|
|
||||||
expect(processStreamingMarkdown(" * ")).toBe(" ");
|
|
||||||
expect(processStreamingMarkdown(" - ")).toBe(" ");
|
|
||||||
expect(processStreamingMarkdown("\t+ ")).toBe("\t");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Heading markers", () => {
|
|
||||||
it("should preserve complete headings", () => {
|
|
||||||
expect(processStreamingMarkdown("# Complete Heading")).toBe(
|
|
||||||
"# Complete Heading",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("## Subheading")).toBe("## Subheading");
|
|
||||||
expect(processStreamingMarkdown("### H3 Title")).toBe("### H3 Title");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not affect # in other contexts", () => {
|
|
||||||
expect(processStreamingMarkdown("C# programming")).toBe("C# programming");
|
|
||||||
expect(processStreamingMarkdown("Issue #123")).toBe("Issue #123");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Bold text", () => {
|
|
||||||
it("should close incomplete bold text", () => {
|
|
||||||
expect(processStreamingMarkdown("This is **bold text")).toBe(
|
|
||||||
"This is **bold text**",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("Start **bold and more")).toBe(
|
|
||||||
"Start **bold and more**",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("**just bold")).toBe("**just bold**");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not affect complete bold text", () => {
|
|
||||||
expect(processStreamingMarkdown("**complete bold**")).toBe(
|
|
||||||
"**complete bold**",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("Text **bold** more")).toBe(
|
|
||||||
"Text **bold** more",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle nested bold correctly", () => {
|
|
||||||
expect(processStreamingMarkdown("**bold** and **another")).toBe(
|
|
||||||
"**bold** and **another**",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Italic text", () => {
|
|
||||||
it("should close incomplete italic text", () => {
|
|
||||||
expect(processStreamingMarkdown("This is *italic text")).toBe(
|
|
||||||
"This is *italic text*",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("Start *italic and more")).toBe(
|
|
||||||
"Start *italic and more*",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should differentiate between list markers and italic", () => {
|
|
||||||
expect(processStreamingMarkdown("* Item\n* ")).toBe("* Item");
|
|
||||||
expect(processStreamingMarkdown("Some *italic text")).toBe(
|
|
||||||
"Some *italic text*",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("*just italic")).toBe("*just italic*");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not affect complete italic text", () => {
|
|
||||||
expect(processStreamingMarkdown("*complete italic*")).toBe(
|
|
||||||
"*complete italic*",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("Text *italic* more")).toBe(
|
|
||||||
"Text *italic* more",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Code blocks", () => {
|
|
||||||
it("should close incomplete code blocks", () => {
|
|
||||||
expect(processStreamingMarkdown("```javascript\nconst x = 42;")).toBe(
|
|
||||||
"```javascript\nconst x = 42;\n```",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("```\ncode here")).toBe(
|
|
||||||
"```\ncode here\n```",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not affect complete code blocks", () => {
|
|
||||||
expect(processStreamingMarkdown("```\ncode\n```")).toBe("```\ncode\n```");
|
|
||||||
expect(processStreamingMarkdown("```js\nconst x = 1;\n```")).toBe(
|
|
||||||
"```js\nconst x = 1;\n```",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle nested code blocks correctly", () => {
|
|
||||||
expect(processStreamingMarkdown("```\ncode\n```\n```python")).toBe(
|
|
||||||
"```\ncode\n```\n```python\n```",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not process markdown inside code blocks", () => {
|
|
||||||
expect(processStreamingMarkdown("```\n* not a list\n**not bold**")).toBe(
|
|
||||||
"```\n* not a list\n**not bold**\n```",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Inline code", () => {
|
|
||||||
it("should close incomplete inline code", () => {
|
|
||||||
expect(processStreamingMarkdown("This is `inline code")).toBe(
|
|
||||||
"This is `inline code`",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("Use `console.log")).toBe(
|
|
||||||
"Use `console.log`",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not affect complete inline code", () => {
|
|
||||||
expect(processStreamingMarkdown("`complete code`")).toBe(
|
|
||||||
"`complete code`",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("Use `code` here")).toBe(
|
|
||||||
"Use `code` here",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle multiple inline codes correctly", () => {
|
|
||||||
expect(processStreamingMarkdown("`code` and `more")).toBe(
|
|
||||||
"`code` and `more`",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should not confuse inline code with code blocks", () => {
|
|
||||||
expect(processStreamingMarkdown("```\nblock\n```\n`inline")).toBe(
|
|
||||||
"```\nblock\n```\n`inline`",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Complex streaming scenarios", () => {
|
|
||||||
it("should handle progressive streaming of a heading", () => {
|
|
||||||
const steps = [
|
|
||||||
{ input: "#", expected: "#" }, // # alone is not removed (needs space)
|
|
||||||
{ input: "# ", expected: "" },
|
|
||||||
{ input: "# H", expected: "# H" },
|
|
||||||
{ input: "# Hello", expected: "# Hello" },
|
|
||||||
];
|
|
||||||
steps.forEach(({ input, expected }) => {
|
|
||||||
expect(processStreamingMarkdown(input)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle progressive streaming of bold text", () => {
|
|
||||||
const steps = [
|
|
||||||
{ input: "*", expected: "" },
|
|
||||||
{ input: "**", expected: "" },
|
|
||||||
{ input: "**b", expected: "**b**" },
|
|
||||||
{ input: "**bold", expected: "**bold**" },
|
|
||||||
{ input: "**bold**", expected: "**bold**" },
|
|
||||||
];
|
|
||||||
steps.forEach(({ input, expected }) => {
|
|
||||||
expect(processStreamingMarkdown(input)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle multiline content with various patterns", () => {
|
|
||||||
const multiline = `# Title
|
|
||||||
|
|
||||||
This is a paragraph with **bold text** and *italic text*.
|
|
||||||
|
|
||||||
* Item 1
|
|
||||||
* Item 2
|
|
||||||
* `;
|
|
||||||
|
|
||||||
const expected = `# Title
|
|
||||||
|
|
||||||
This is a paragraph with **bold text** and *italic text*.
|
|
||||||
|
|
||||||
* Item 1
|
|
||||||
* Item 2`;
|
|
||||||
|
|
||||||
expect(processStreamingMarkdown(multiline)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should only fix the last line", () => {
|
|
||||||
expect(processStreamingMarkdown("# Complete\n# Another\n# ")).toBe(
|
|
||||||
"# Complete\n# Another",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("* Item 1\n* Item 2\n* ")).toBe(
|
|
||||||
"* Item 1\n* Item 2",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle mixed content correctly", () => {
|
|
||||||
const input = `# Header
|
|
||||||
|
|
||||||
This has **bold** text and *italic* text.
|
|
||||||
|
|
||||||
\`\`\`js
|
|
||||||
const x = 42;
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Now some \`inline code\` and **unclosed bold`;
|
|
||||||
|
|
||||||
const expected = `# Header
|
|
||||||
|
|
||||||
This has **bold** text and *italic* text.
|
|
||||||
|
|
||||||
\`\`\`js
|
|
||||||
const x = 42;
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
Now some \`inline code\` and **unclosed bold**`;
|
|
||||||
|
|
||||||
expect(processStreamingMarkdown(input)).toBe(expected);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Edge cases with escaping", () => {
|
|
||||||
it("should handle escaped asterisks (future enhancement)", () => {
|
|
||||||
// Note: Current implementation doesn't handle escaping
|
|
||||||
// This is a known limitation - escaped characters still trigger closing
|
|
||||||
expect(processStreamingMarkdown("Text \\*not italic")).toBe(
|
|
||||||
"Text \\*not italic*",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should handle escaped backticks (future enhancement)", () => {
|
|
||||||
// Note: Current implementation doesn't handle escaping
|
|
||||||
// This is a known limitation - escaped characters still trigger closing
|
|
||||||
expect(processStreamingMarkdown("Text \\`not code")).toBe(
|
|
||||||
"Text \\`not code`",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("Code block edge cases", () => {
|
|
||||||
it("should handle triple backticks in the middle of lines", () => {
|
|
||||||
expect(processStreamingMarkdown("Text ``` in middle")).toBe(
|
|
||||||
"Text ``` in middle\n```",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("```\nText ``` in code\nmore")).toBe(
|
|
||||||
"```\nText ``` in code\nmore\n```",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should properly close code blocks with language specifiers", () => {
|
|
||||||
expect(processStreamingMarkdown("```typescript")).toBe(
|
|
||||||
"```typescript\n```",
|
|
||||||
);
|
|
||||||
expect(processStreamingMarkdown("```typescript\nconst x = 1")).toBe(
|
|
||||||
"```typescript\nconst x = 1\n```",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove a completely empty partial code block", () => {
|
|
||||||
expect(processStreamingMarkdown("```\n")).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
*/
|
|
||||||
@@ -1,66 +1,126 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Markdown from "react-markdown";
|
import { Streamdown, defaultRemarkPlugins } from "streamdown";
|
||||||
import remarkGfm from "remark-gfm";
|
|
||||||
import remarkMath from "remark-math";
|
|
||||||
import rehypeRaw from "rehype-raw";
|
|
||||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
|
||||||
import rehypePrismPlus from "rehype-prism-plus";
|
|
||||||
import rehypeKatex from "rehype-katex";
|
|
||||||
import remarkStreamingMarkdown, {
|
|
||||||
type LastNodeInfo,
|
|
||||||
} from "@/utils/remarkStreamingMarkdown";
|
|
||||||
import type { PluggableList } from "unified";
|
|
||||||
import remarkCitationParser from "@/utils/remarkCitationParser";
|
import remarkCitationParser from "@/utils/remarkCitationParser";
|
||||||
import CopyButton from "./CopyButton";
|
import CopyButton from "./CopyButton";
|
||||||
|
import { codeToTokens, type BundledLanguage } from "shiki";
|
||||||
|
|
||||||
interface StreamingMarkdownContentProps {
|
interface StreamingMarkdownContentProps {
|
||||||
content: string;
|
content: string;
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
onLastNode?: (info: LastNodeInfo) => void;
|
|
||||||
browserToolResult?: any; // TODO: proper type
|
browserToolResult?: any; // TODO: proper type
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodeBlock = React.memo(
|
// Helper to extract text from React nodes
|
||||||
({ children, className, ...props }: React.HTMLAttributes<HTMLPreElement>) => {
|
const extractText = (node: React.ReactNode): string => {
|
||||||
const extractText = React.useCallback((node: React.ReactNode): string => {
|
|
||||||
if (typeof node === "string") return node;
|
if (typeof node === "string") return node;
|
||||||
if (typeof node === "number") return String(node);
|
if (typeof node === "number") return String(node);
|
||||||
if (!node) return "";
|
if (!node) return "";
|
||||||
|
|
||||||
if (React.isValidElement(node)) {
|
if (React.isValidElement(node)) {
|
||||||
if (
|
const props = node.props as any;
|
||||||
node.props &&
|
if (props?.children) {
|
||||||
typeof node.props === "object" &&
|
return extractText(props.children as React.ReactNode);
|
||||||
"children" in node.props
|
|
||||||
) {
|
|
||||||
return extractText(node.props.children as React.ReactNode);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(node)) {
|
if (Array.isArray(node)) {
|
||||||
return node.map(extractText).join("");
|
return node.map(extractText).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
return "";
|
return "";
|
||||||
}, []);
|
};
|
||||||
|
|
||||||
const language = className?.replace(/language-/, "") || "";
|
const CodeBlock = React.memo(
|
||||||
|
({ children }: React.HTMLAttributes<HTMLPreElement>) => {
|
||||||
|
const [lightTokens, setLightTokens] = React.useState<any>(null);
|
||||||
|
const [darkTokens, setDarkTokens] = React.useState<any>(null);
|
||||||
|
|
||||||
|
// Extract code and language from children
|
||||||
|
const codeElement = children as React.ReactElement<{
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>;
|
||||||
|
const language =
|
||||||
|
codeElement.props.className?.replace(/language-/, "") || "";
|
||||||
|
const codeText = extractText(codeElement.props.children);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
async function highlight() {
|
||||||
|
try {
|
||||||
|
const [light, dark] = await Promise.all([
|
||||||
|
codeToTokens(codeText, {
|
||||||
|
lang: language as BundledLanguage,
|
||||||
|
theme: "github-light",
|
||||||
|
}),
|
||||||
|
codeToTokens(codeText, {
|
||||||
|
lang: language as BundledLanguage,
|
||||||
|
theme: "github-dark",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
setLightTokens(light);
|
||||||
|
setDarkTokens(dark);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to highlight code:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
highlight();
|
||||||
|
}, [codeText, language]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden my-6">
|
<div className="relative bg-neutral-100 dark:bg-neutral-800 rounded-2xl overflow-hidden my-6">
|
||||||
<div className="flex justify-between select-none">
|
<div className="flex select-none">
|
||||||
|
{language && (
|
||||||
<div className="text-[13px] text-neutral-500 dark:text-neutral-400 font-mono px-4 py-2">
|
<div className="text-[13px] text-neutral-500 dark:text-neutral-400 font-mono px-4 py-2">
|
||||||
{language}
|
{language}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<CopyButton
|
<CopyButton
|
||||||
content={extractText(children)}
|
content={codeText}
|
||||||
showLabels={true}
|
showLabels={true}
|
||||||
className="copy-button text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800"
|
className="copy-button text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 ml-auto"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<pre className={className} {...props}>
|
{/* Light mode */}
|
||||||
{children}
|
<pre className="dark:hidden m-0 bg-neutral-100 text-sm overflow-x-auto p-4">
|
||||||
|
<code className="font-mono text-sm">
|
||||||
|
{lightTokens
|
||||||
|
? lightTokens.tokens.map((line: any, i: number) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{line.map((token: any, j: number) => (
|
||||||
|
<span
|
||||||
|
key={j}
|
||||||
|
style={{
|
||||||
|
color: token.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.content}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{i < lightTokens.tokens.length - 1 && "\n"}
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
: codeText}
|
||||||
|
</code>
|
||||||
|
</pre>
|
||||||
|
{/* Dark mode */}
|
||||||
|
<pre className="hidden dark:block m-0 bg-neutral-800 text-sm overflow-x-auto p-4">
|
||||||
|
<code className="font-mono text-sm">
|
||||||
|
{darkTokens
|
||||||
|
? darkTokens.tokens.map((line: any, i: number) => (
|
||||||
|
<React.Fragment key={i}>
|
||||||
|
{line.map((token: any, j: number) => (
|
||||||
|
<span
|
||||||
|
key={j}
|
||||||
|
style={{
|
||||||
|
color: token.color,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{token.content}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{i < darkTokens.tokens.length - 1 && "\n"}
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
: codeText}
|
||||||
|
</code>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -68,60 +128,14 @@ const CodeBlock = React.memo(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
||||||
React.memo(
|
React.memo(({ content, isStreaming = false, size, browserToolResult }) => {
|
||||||
({ content, isStreaming = false, size, onLastNode, browserToolResult }) => {
|
// Build the remark plugins array - keep default GFM and Math, add citations
|
||||||
// Build the remark plugins array
|
|
||||||
const remarkPlugins = React.useMemo(() => {
|
const remarkPlugins = React.useMemo(() => {
|
||||||
const plugins: PluggableList = [
|
return [
|
||||||
remarkGfm,
|
defaultRemarkPlugins.gfm,
|
||||||
[remarkMath, { singleDollarTextMath: false }],
|
defaultRemarkPlugins.math,
|
||||||
remarkCitationParser,
|
remarkCitationParser,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add streaming plugin when in streaming mode
|
|
||||||
if (isStreaming) {
|
|
||||||
plugins.push([remarkStreamingMarkdown, { debug: true, onLastNode }]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugins;
|
|
||||||
}, [isStreaming, onLastNode]);
|
|
||||||
|
|
||||||
// Create a custom sanitization schema that allows math elements
|
|
||||||
const sanitizeSchema = React.useMemo(() => {
|
|
||||||
return {
|
|
||||||
...defaultSchema,
|
|
||||||
attributes: {
|
|
||||||
...defaultSchema.attributes,
|
|
||||||
span: [
|
|
||||||
...(defaultSchema.attributes?.span || []),
|
|
||||||
["className", /^katex/],
|
|
||||||
],
|
|
||||||
div: [
|
|
||||||
...(defaultSchema.attributes?.div || []),
|
|
||||||
["className", /^katex/],
|
|
||||||
],
|
|
||||||
"ol-citation": ["cursor", "start", "end"],
|
|
||||||
},
|
|
||||||
tagNames: [
|
|
||||||
...(defaultSchema.tagNames || []),
|
|
||||||
"math",
|
|
||||||
"mrow",
|
|
||||||
"mi",
|
|
||||||
"mo",
|
|
||||||
"mn",
|
|
||||||
"msup",
|
|
||||||
"msub",
|
|
||||||
"mfrac",
|
|
||||||
"mover",
|
|
||||||
"munder",
|
|
||||||
"msqrt",
|
|
||||||
"mroot",
|
|
||||||
"merror",
|
|
||||||
"mspace",
|
|
||||||
"mpadded",
|
|
||||||
"ol-citation",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -172,23 +186,10 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
|||||||
content={content}
|
content={content}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
>
|
>
|
||||||
<Markdown
|
<Streamdown
|
||||||
|
parseIncompleteMarkdown={isStreaming}
|
||||||
|
isAnimating={isStreaming}
|
||||||
remarkPlugins={remarkPlugins}
|
remarkPlugins={remarkPlugins}
|
||||||
rehypePlugins={
|
|
||||||
[
|
|
||||||
[rehypeRaw, { allowDangerousHtml: true }],
|
|
||||||
[rehypeSanitize, sanitizeSchema],
|
|
||||||
[rehypePrismPlus, { ignoreMissing: true }],
|
|
||||||
[
|
|
||||||
rehypeKatex,
|
|
||||||
{
|
|
||||||
errorColor: "#000000", // Black instead of red for errors
|
|
||||||
strict: false, // Be more lenient with parsing
|
|
||||||
throwOnError: false,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
] as PluggableList
|
|
||||||
}
|
|
||||||
components={{
|
components={{
|
||||||
pre: CodeBlock,
|
pre: CodeBlock,
|
||||||
table: ({
|
table: ({
|
||||||
@@ -196,38 +197,65 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
|||||||
...props
|
...props
|
||||||
}: React.HTMLAttributes<HTMLTableElement>) => (
|
}: React.HTMLAttributes<HTMLTableElement>) => (
|
||||||
<div className="overflow-x-auto max-w-full">
|
<div className="overflow-x-auto max-w-full">
|
||||||
<table {...props}>{children}</table>
|
<table
|
||||||
|
{...props}
|
||||||
|
className="w-full border-separate border-spacing-0 rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
// @ts-expect-error: custom type
|
thead: ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLTableSectionElement>) => (
|
||||||
|
<thead {...props} className="bg-neutral-50 dark:bg-neutral-800">
|
||||||
|
{children}
|
||||||
|
</thead>
|
||||||
|
),
|
||||||
|
th: ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLTableCellElement>) => (
|
||||||
|
<th
|
||||||
|
{...props}
|
||||||
|
className="px-3 py-2 text-left font-semibold border-b border-r border-neutral-200 dark:border-neutral-700 last:border-r-0"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
),
|
||||||
|
td: ({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLTableCellElement>) => (
|
||||||
|
<td
|
||||||
|
{...props}
|
||||||
|
className="px-3 py-2 border-r border-neutral-200 dark:border-neutral-700 last:border-r-0 [tr:not(:last-child)_&]:border-b"
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
),
|
||||||
|
// @ts-expect-error: custom citation type
|
||||||
"ol-citation": ({
|
"ol-citation": ({
|
||||||
cursor,
|
cursor,
|
||||||
// start,
|
|
||||||
// end,
|
|
||||||
}: {
|
}: {
|
||||||
cursor: number;
|
cursor: number;
|
||||||
start: number;
|
start: number;
|
||||||
end: number;
|
end: number;
|
||||||
}) => {
|
}) => {
|
||||||
// Check if we have a page_stack and if the cursor is valid
|
|
||||||
const pageStack = browserToolResult?.page_stack;
|
const pageStack = browserToolResult?.page_stack;
|
||||||
const hasValidPage = pageStack && cursor < pageStack.length;
|
const hasValidPage = pageStack && cursor < pageStack.length;
|
||||||
const pageUrl = hasValidPage ? pageStack[cursor] : null;
|
const pageUrl = hasValidPage ? pageStack[cursor] : null;
|
||||||
|
|
||||||
// Extract a readable title from the URL if possible
|
|
||||||
const getPageTitle = (url: string) => {
|
const getPageTitle = (url: string) => {
|
||||||
if (url.startsWith("search_results_")) {
|
if (url.startsWith("search_results_")) {
|
||||||
const searchTerm = url.substring(
|
const searchTerm = url.substring("search_results_".length);
|
||||||
"search_results_".length,
|
|
||||||
);
|
|
||||||
return `Search: ${searchTerm}`;
|
return `Search: ${searchTerm}`;
|
||||||
}
|
}
|
||||||
// For regular URLs, try to extract domain or use full URL
|
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
return urlObj.hostname;
|
return urlObj.hostname;
|
||||||
} catch {
|
} catch {
|
||||||
// If not a valid URL, return as is
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -238,7 +266,6 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
// If we have a valid page URL, wrap in a link
|
|
||||||
if (pageUrl && pageUrl.startsWith("http")) {
|
if (pageUrl && pageUrl.startsWith("http")) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@@ -253,18 +280,16 @@ const StreamingMarkdownContent: React.FC<StreamingMarkdownContentProps> =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, just return the citation without a link
|
|
||||||
return citationElement;
|
return citationElement;
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{content}
|
{content}
|
||||||
</Markdown>
|
</Streamdown>
|
||||||
</StreamingMarkdownErrorBoundary>
|
</StreamingMarkdownErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
interface StreamingMarkdownErrorBoundaryProps {
|
interface StreamingMarkdownErrorBoundaryProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
|||||||
@@ -16,793 +16,6 @@
|
|||||||
--text-color: #ffffff;
|
--text-color: #ffffff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: light) {
|
|
||||||
.prose {
|
|
||||||
/**
|
|
||||||
* One Light theme for prism.js
|
|
||||||
* Based on Atom's One Light theme: https://github.com/atom/atom/tree/master/packages/one-light-syntax
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Light colours (accurate as of commit eb064bf on 19 Feb 2021)
|
|
||||||
* From colors.less
|
|
||||||
* --mono-1: hsl(230, 8%, 24%);
|
|
||||||
* --mono-2: hsl(230, 6%, 44%);
|
|
||||||
* --mono-3: hsl(230, 4%, 64%)
|
|
||||||
* --hue-1: hsl(198, 99%, 37%);
|
|
||||||
* --hue-2: hsl(221, 87%, 60%);
|
|
||||||
* --hue-3: hsl(301, 63%, 40%);
|
|
||||||
* --hue-4: hsl(119, 34%, 47%);
|
|
||||||
* --hue-5: hsl(5, 74%, 59%);
|
|
||||||
* --hue-5-2: hsl(344, 84%, 43%);
|
|
||||||
* --hue-6: hsl(35, 99%, 36%);
|
|
||||||
* --hue-6-2: hsl(35, 99%, 40%);
|
|
||||||
* --syntax-fg: hsl(230, 8%, 24%);
|
|
||||||
* --syntax-bg: hsl(230, 1%, 98%);
|
|
||||||
* --syntax-gutter: hsl(230, 1%, 62%);
|
|
||||||
* --syntax-guide: hsla(230, 8%, 24%, 0.2);
|
|
||||||
* --syntax-accent: hsl(230, 100%, 66%);
|
|
||||||
* From syntax-variables.less
|
|
||||||
* --syntax-selection-color: hsl(230, 1%, 90%);
|
|
||||||
* --syntax-gutter-background-color-selected: hsl(230, 1%, 90%);
|
|
||||||
* --syntax-cursor-line: hsla(230, 8%, 24%, 0.05);
|
|
||||||
*/
|
|
||||||
|
|
||||||
.token.comment,
|
|
||||||
.token.prolog,
|
|
||||||
.token.cdata {
|
|
||||||
color: hsl(230, 4%, 64%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.doctype,
|
|
||||||
.token.punctuation,
|
|
||||||
.token.entity {
|
|
||||||
color: hsl(230, 8%, 24%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.attr-name,
|
|
||||||
.token.class-name,
|
|
||||||
.token.boolean,
|
|
||||||
.token.constant,
|
|
||||||
.token.number,
|
|
||||||
.token.atrule {
|
|
||||||
color: hsl(35, 99%, 36%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.keyword {
|
|
||||||
color: hsl(301, 63%, 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.property,
|
|
||||||
.token.tag,
|
|
||||||
.token.symbol,
|
|
||||||
.token.deleted,
|
|
||||||
.token.important {
|
|
||||||
color: hsl(5, 74%, 59%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.selector,
|
|
||||||
.token.string,
|
|
||||||
.token.char,
|
|
||||||
.token.builtin,
|
|
||||||
.token.inserted,
|
|
||||||
.token.regex,
|
|
||||||
.token.attr-value,
|
|
||||||
.token.attr-value > .token.punctuation {
|
|
||||||
color: hsl(119, 34%, 47%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.variable,
|
|
||||||
.token.operator,
|
|
||||||
.token.function {
|
|
||||||
color: hsl(221, 87%, 60%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.url {
|
|
||||||
color: hsl(198, 99%, 37%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* HTML overrides */
|
|
||||||
.token.attr-value > .token.punctuation.attr-equals,
|
|
||||||
.token.special-attr > .token.attr-value > .token.value.css {
|
|
||||||
color: hsl(230, 8%, 24%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CSS overrides */
|
|
||||||
.language-css .token.selector {
|
|
||||||
color: hsl(5, 74%, 59%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-css .token.property {
|
|
||||||
color: hsl(230, 8%, 24%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-css .token.function,
|
|
||||||
.language-css .token.url > .token.function {
|
|
||||||
color: hsl(198, 99%, 37%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-css .token.url > .token.string.url {
|
|
||||||
color: hsl(119, 34%, 47%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-css .token.important,
|
|
||||||
.language-css .token.atrule .token.rule {
|
|
||||||
color: hsl(301, 63%, 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* JS overrides */
|
|
||||||
.language-javascript .token.operator {
|
|
||||||
color: hsl(301, 63%, 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-javascript
|
|
||||||
.token.template-string
|
|
||||||
> .token.interpolation
|
|
||||||
> .token.interpolation-punctuation.punctuation {
|
|
||||||
color: hsl(344, 84%, 43%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* JSON overrides */
|
|
||||||
.language-json .token.operator {
|
|
||||||
color: hsl(230, 8%, 24%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-json .token.null.keyword {
|
|
||||||
color: hsl(35, 99%, 36%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MD overrides */
|
|
||||||
.language-markdown .token.url,
|
|
||||||
.language-markdown .token.url > .token.operator,
|
|
||||||
.language-markdown .token.url-reference.url > .token.string {
|
|
||||||
color: hsl(230, 8%, 24%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.url > .token.content {
|
|
||||||
color: hsl(221, 87%, 60%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.url > .token.url,
|
|
||||||
.language-markdown .token.url-reference.url {
|
|
||||||
color: hsl(198, 99%, 37%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.blockquote.punctuation,
|
|
||||||
.language-markdown .token.hr.punctuation {
|
|
||||||
color: hsl(230, 4%, 64%);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.code-snippet {
|
|
||||||
color: hsl(119, 34%, 47%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.bold .token.content {
|
|
||||||
color: hsl(35, 99%, 36%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.italic .token.content {
|
|
||||||
color: hsl(301, 63%, 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.strike .token.content,
|
|
||||||
.language-markdown .token.strike .token.punctuation,
|
|
||||||
.language-markdown .token.list.punctuation,
|
|
||||||
.language-markdown .token.title.important > .token.punctuation {
|
|
||||||
color: hsl(5, 74%, 59%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* General */
|
|
||||||
.token.bold {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.comment,
|
|
||||||
.token.italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.entity {
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.namespace {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Plugin overrides */
|
|
||||||
/* Selectors should have higher specificity than those in the plugins' default stylesheets */
|
|
||||||
|
|
||||||
/* Show Invisibles plugin overrides */
|
|
||||||
.token.token.tab:not(:empty):before,
|
|
||||||
.token.token.cr:before,
|
|
||||||
.token.token.lf:before,
|
|
||||||
.token.token.space:before {
|
|
||||||
color: hsla(230, 8%, 24%, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar plugin overrides */
|
|
||||||
/* Space out all buttons and move them away from the right edge of the code block */
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item {
|
|
||||||
margin-right: 0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling the buttons */
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
|
|
||||||
background: hsl(230, 1%, 90%);
|
|
||||||
color: hsl(230, 6%, 44%);
|
|
||||||
padding: 0.1em 0.4em;
|
|
||||||
border-radius: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
|
|
||||||
background: hsl(230, 1%, 78%); /* custom: darken(--syntax-bg, 20%) */
|
|
||||||
color: hsl(230, 8%, 24%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Line Highlight plugin overrides */
|
|
||||||
/* The highlighted line itself */
|
|
||||||
.line-highlight.line-highlight {
|
|
||||||
background: hsla(230, 8%, 24%, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Default line numbers in Line Highlight plugin */
|
|
||||||
.line-highlight.line-highlight:before,
|
|
||||||
.line-highlight.line-highlight[data-end]:after {
|
|
||||||
background: hsl(230, 1%, 90%);
|
|
||||||
color: hsl(230, 8%, 24%);
|
|
||||||
padding: 0.1em 0.6em;
|
|
||||||
border-radius: 0.3em;
|
|
||||||
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hovering over a linkable line number (in the gutter area) */
|
|
||||||
/* Requires Line Numbers plugin as well */
|
|
||||||
pre[id].linkable-line-numbers.linkable-line-numbers
|
|
||||||
span.line-numbers-rows
|
|
||||||
> span:hover:before {
|
|
||||||
background-color: hsla(230, 8%, 24%, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Line Numbers and Command Line plugins overrides */
|
|
||||||
/* Line separating gutter from coding area */
|
|
||||||
.line-numbers.line-numbers .line-numbers-rows,
|
|
||||||
.command-line .command-line-prompt {
|
|
||||||
border-right-color: hsla(230, 8%, 24%, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stuff in the gutter */
|
|
||||||
.line-numbers .line-numbers-rows > span:before,
|
|
||||||
.command-line .command-line-prompt > span:before {
|
|
||||||
color: hsl(230, 1%, 62%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Match Braces plugin overrides */
|
|
||||||
/* Note: Outline colour is inherited from the braces */
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-1,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-5,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-9 {
|
|
||||||
color: hsl(5, 74%, 59%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-2,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-6,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-10 {
|
|
||||||
color: hsl(119, 34%, 47%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-3,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-7,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-11 {
|
|
||||||
color: hsl(221, 87%, 60%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-4,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-8,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-12 {
|
|
||||||
color: hsl(301, 63%, 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Diff Highlight plugin overrides */
|
|
||||||
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
|
|
||||||
pre.diff-highlight > code .token.token.deleted:not(.prefix),
|
|
||||||
pre > code.diff-highlight .token.token.deleted:not(.prefix) {
|
|
||||||
background-color: hsla(353, 100%, 66%, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
|
|
||||||
pre.diff-highlight
|
|
||||||
> code
|
|
||||||
.token.token.deleted:not(.prefix)
|
|
||||||
*::-moz-selection,
|
|
||||||
pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
|
|
||||||
pre
|
|
||||||
> code.diff-highlight
|
|
||||||
.token.token.deleted:not(.prefix)
|
|
||||||
*::-moz-selection {
|
|
||||||
background-color: hsla(353, 95%, 66%, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
|
|
||||||
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
|
|
||||||
pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
|
|
||||||
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
|
|
||||||
background-color: hsla(353, 95%, 66%, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight > code .token.token.inserted:not(.prefix),
|
|
||||||
pre > code.diff-highlight .token.token.inserted:not(.prefix) {
|
|
||||||
background-color: hsla(137, 100%, 55%, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight
|
|
||||||
> code
|
|
||||||
.token.token.inserted:not(.prefix)::-moz-selection,
|
|
||||||
pre.diff-highlight
|
|
||||||
> code
|
|
||||||
.token.token.inserted:not(.prefix)
|
|
||||||
*::-moz-selection,
|
|
||||||
pre
|
|
||||||
> code.diff-highlight
|
|
||||||
.token.token.inserted:not(.prefix)::-moz-selection,
|
|
||||||
pre
|
|
||||||
> code.diff-highlight
|
|
||||||
.token.token.inserted:not(.prefix)
|
|
||||||
*::-moz-selection {
|
|
||||||
background-color: hsla(135, 73%, 55%, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
|
|
||||||
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
|
|
||||||
pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
|
|
||||||
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
|
|
||||||
background-color: hsla(135, 73%, 55%, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Previewers plugin overrides */
|
|
||||||
/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-light-ui */
|
|
||||||
/* Border around popup */
|
|
||||||
.prism-previewer.prism-previewer:before,
|
|
||||||
.prism-previewer-gradient.prism-previewer-gradient div {
|
|
||||||
border-color: hsl(0, 0, 95%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Angle and time should remain as circles and are hence not included */
|
|
||||||
.prism-previewer-color.prism-previewer-color:before,
|
|
||||||
.prism-previewer-gradient.prism-previewer-gradient div,
|
|
||||||
.prism-previewer-easing.prism-previewer-easing:before {
|
|
||||||
border-radius: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Triangles pointing to the code */
|
|
||||||
.prism-previewer.prism-previewer:after {
|
|
||||||
border-top-color: hsl(0, 0, 95%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prism-previewer-flipped.prism-previewer-flipped.after {
|
|
||||||
border-bottom-color: hsl(0, 0, 95%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Background colour within the popup */
|
|
||||||
.prism-previewer-angle.prism-previewer-angle:before,
|
|
||||||
.prism-previewer-time.prism-previewer-time:before,
|
|
||||||
.prism-previewer-easing.prism-previewer-easing {
|
|
||||||
background: hsl(0, 0%, 100%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */
|
|
||||||
/* For time, this is the alternate colour */
|
|
||||||
.prism-previewer-angle.prism-previewer-angle circle,
|
|
||||||
.prism-previewer-time.prism-previewer-time circle {
|
|
||||||
stroke: hsl(230, 8%, 24%);
|
|
||||||
stroke-opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stroke colours of the handle, direction point, and vector itself */
|
|
||||||
.prism-previewer-easing.prism-previewer-easing circle,
|
|
||||||
.prism-previewer-easing.prism-previewer-easing path,
|
|
||||||
.prism-previewer-easing.prism-previewer-easing line {
|
|
||||||
stroke: hsl(230, 8%, 24%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fill colour of the handle */
|
|
||||||
.prism-previewer-easing.prism-previewer-easing circle {
|
|
||||||
fill: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
.prose {
|
|
||||||
.token.comment,
|
|
||||||
.token.prolog,
|
|
||||||
.token.cdata {
|
|
||||||
color: hsl(220, 10%, 40%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.doctype,
|
|
||||||
.token.punctuation,
|
|
||||||
.token.entity {
|
|
||||||
color: hsl(220, 14%, 71%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.attr-name,
|
|
||||||
.token.class-name,
|
|
||||||
.token.boolean,
|
|
||||||
.token.constant,
|
|
||||||
.token.number,
|
|
||||||
.token.atrule {
|
|
||||||
color: hsl(29, 54%, 61%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.keyword {
|
|
||||||
color: hsl(286, 60%, 67%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.property,
|
|
||||||
.token.tag,
|
|
||||||
.token.symbol,
|
|
||||||
.token.deleted,
|
|
||||||
.token.important {
|
|
||||||
color: hsl(355, 65%, 65%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.selector,
|
|
||||||
.token.string,
|
|
||||||
.token.char,
|
|
||||||
.token.builtin,
|
|
||||||
.token.inserted,
|
|
||||||
.token.regex,
|
|
||||||
.token.attr-value,
|
|
||||||
.token.attr-value > .token.punctuation {
|
|
||||||
color: hsl(95, 38%, 62%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.variable,
|
|
||||||
.token.operator,
|
|
||||||
.token.function {
|
|
||||||
color: hsl(207, 82%, 66%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.url {
|
|
||||||
color: hsl(187, 47%, 55%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* HTML overrides */
|
|
||||||
.token.attr-value > .token.punctuation.attr-equals,
|
|
||||||
.token.special-attr > .token.attr-value > .token.value.css {
|
|
||||||
color: hsl(220, 14%, 71%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CSS overrides */
|
|
||||||
.language-css .token.selector {
|
|
||||||
color: hsl(355, 65%, 65%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-css .token.property {
|
|
||||||
color: hsl(220, 14%, 71%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-css .token.function,
|
|
||||||
.language-css .token.url > .token.function {
|
|
||||||
color: hsl(187, 47%, 55%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-css .token.url > .token.string.url {
|
|
||||||
color: hsl(95, 38%, 62%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-css .token.important,
|
|
||||||
.language-css .token.atrule .token.rule {
|
|
||||||
color: hsl(286, 60%, 67%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* JS overrides */
|
|
||||||
.language-javascript .token.operator {
|
|
||||||
color: hsl(286, 60%, 67%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-javascript
|
|
||||||
.token.template-string
|
|
||||||
> .token.interpolation
|
|
||||||
> .token.interpolation-punctuation.punctuation {
|
|
||||||
color: hsl(5, 48%, 51%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* JSON overrides */
|
|
||||||
.language-json .token.operator {
|
|
||||||
color: hsl(220, 14%, 71%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-json .token.null.keyword {
|
|
||||||
color: hsl(29, 54%, 61%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MD overrides */
|
|
||||||
.language-markdown .token.url,
|
|
||||||
.language-markdown .token.url > .token.operator,
|
|
||||||
.language-markdown .token.url-reference.url > .token.string {
|
|
||||||
color: hsl(220, 14%, 71%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.url > .token.content {
|
|
||||||
color: hsl(207, 82%, 66%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.url > .token.url,
|
|
||||||
.language-markdown .token.url-reference.url {
|
|
||||||
color: hsl(187, 47%, 55%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.blockquote.punctuation,
|
|
||||||
.language-markdown .token.hr.punctuation {
|
|
||||||
color: hsl(220, 10%, 40%);
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.code-snippet {
|
|
||||||
color: hsl(95, 38%, 62%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.bold .token.content {
|
|
||||||
color: hsl(29, 54%, 61%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.italic .token.content {
|
|
||||||
color: hsl(286, 60%, 67%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.language-markdown .token.strike .token.content,
|
|
||||||
.language-markdown .token.strike .token.punctuation,
|
|
||||||
.language-markdown .token.list.punctuation,
|
|
||||||
.language-markdown .token.title.important > .token.punctuation {
|
|
||||||
color: hsl(355, 65%, 65%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* General */
|
|
||||||
.token.bold {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.comment,
|
|
||||||
.token.italic {
|
|
||||||
font-style: italic;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.entity {
|
|
||||||
cursor: help;
|
|
||||||
}
|
|
||||||
|
|
||||||
.token.namespace {
|
|
||||||
opacity: 0.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Plugin overrides */
|
|
||||||
/* Selectors should have higher specificity than those in the plugins' default stylesheets */
|
|
||||||
|
|
||||||
/* Show Invisibles plugin overrides */
|
|
||||||
.token.token.tab:not(:empty):before,
|
|
||||||
.token.token.cr:before,
|
|
||||||
.token.token.lf:before,
|
|
||||||
.token.token.space:before {
|
|
||||||
color: hsla(220, 14%, 71%, 0.15);
|
|
||||||
text-shadow: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Toolbar plugin overrides */
|
|
||||||
/* Space out all buttons and move them away from the right edge of the code block */
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item {
|
|
||||||
margin-right: 0.4em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Styling the buttons */
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span {
|
|
||||||
background: hsl(220, 13%, 26%);
|
|
||||||
color: hsl(220, 9%, 55%);
|
|
||||||
padding: 0.1em 0.4em;
|
|
||||||
border-radius: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:hover,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > button:focus,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:hover,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > a:focus,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:hover,
|
|
||||||
div.code-toolbar > .toolbar.toolbar > .toolbar-item > span:focus {
|
|
||||||
background: hsl(220, 13%, 28%);
|
|
||||||
color: hsl(220, 14%, 71%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Line Highlight plugin overrides */
|
|
||||||
/* The highlighted line itself */
|
|
||||||
.line-highlight.line-highlight {
|
|
||||||
background: hsla(220, 100%, 80%, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Default line numbers in Line Highlight plugin */
|
|
||||||
.line-highlight.line-highlight:before,
|
|
||||||
.line-highlight.line-highlight[data-end]:after {
|
|
||||||
background: hsl(220, 13%, 26%);
|
|
||||||
color: hsl(220, 14%, 71%);
|
|
||||||
padding: 0.1em 0.6em;
|
|
||||||
border-radius: 0.3em;
|
|
||||||
box-shadow: 0 2px 0 0 rgba(0, 0, 0, 0.2); /* same as Toolbar plugin default */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hovering over a linkable line number (in the gutter area) */
|
|
||||||
/* Requires Line Numbers plugin as well */
|
|
||||||
pre[id].linkable-line-numbers.linkable-line-numbers
|
|
||||||
span.line-numbers-rows
|
|
||||||
> span:hover:before {
|
|
||||||
background-color: hsla(220, 100%, 80%, 0.04);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Line Numbers and Command Line plugins overrides */
|
|
||||||
/* Line separating gutter from coding area */
|
|
||||||
.line-numbers.line-numbers .line-numbers-rows,
|
|
||||||
.command-line .command-line-prompt {
|
|
||||||
border-right-color: hsla(220, 14%, 71%, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stuff in the gutter */
|
|
||||||
.line-numbers .line-numbers-rows > span:before,
|
|
||||||
.command-line .command-line-prompt > span:before {
|
|
||||||
color: hsl(220, 14%, 45%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Match Braces plugin overrides */
|
|
||||||
/* Note: Outline colour is inherited from the braces */
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-1,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-5,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-9 {
|
|
||||||
color: hsl(355, 65%, 65%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-2,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-6,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-10 {
|
|
||||||
color: hsl(95, 38%, 62%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-3,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-7,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-11 {
|
|
||||||
color: hsl(207, 82%, 66%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-4,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-8,
|
|
||||||
.rainbow-braces .token.token.punctuation.brace-level-12 {
|
|
||||||
color: hsl(286, 60%, 67%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Diff Highlight plugin overrides */
|
|
||||||
/* Taken from https://github.com/atom/github/blob/master/styles/variables.less */
|
|
||||||
pre.diff-highlight > code .token.token.deleted:not(.prefix),
|
|
||||||
pre > code.diff-highlight .token.token.deleted:not(.prefix) {
|
|
||||||
background-color: hsla(353, 100%, 66%, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight > code .token.token.deleted:not(.prefix)::-moz-selection,
|
|
||||||
pre.diff-highlight
|
|
||||||
> code
|
|
||||||
.token.token.deleted:not(.prefix)
|
|
||||||
*::-moz-selection,
|
|
||||||
pre > code.diff-highlight .token.token.deleted:not(.prefix)::-moz-selection,
|
|
||||||
pre
|
|
||||||
> code.diff-highlight
|
|
||||||
.token.token.deleted:not(.prefix)
|
|
||||||
*::-moz-selection {
|
|
||||||
background-color: hsla(353, 95%, 66%, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight > code .token.token.deleted:not(.prefix)::selection,
|
|
||||||
pre.diff-highlight > code .token.token.deleted:not(.prefix) *::selection,
|
|
||||||
pre > code.diff-highlight .token.token.deleted:not(.prefix)::selection,
|
|
||||||
pre > code.diff-highlight .token.token.deleted:not(.prefix) *::selection {
|
|
||||||
background-color: hsla(353, 95%, 66%, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight > code .token.token.inserted:not(.prefix),
|
|
||||||
pre > code.diff-highlight .token.token.inserted:not(.prefix) {
|
|
||||||
background-color: hsla(137, 100%, 55%, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight
|
|
||||||
> code
|
|
||||||
.token.token.inserted:not(.prefix)::-moz-selection,
|
|
||||||
pre.diff-highlight
|
|
||||||
> code
|
|
||||||
.token.token.inserted:not(.prefix)
|
|
||||||
*::-moz-selection,
|
|
||||||
pre
|
|
||||||
> code.diff-highlight
|
|
||||||
.token.token.inserted:not(.prefix)::-moz-selection,
|
|
||||||
pre
|
|
||||||
> code.diff-highlight
|
|
||||||
.token.token.inserted:not(.prefix)
|
|
||||||
*::-moz-selection {
|
|
||||||
background-color: hsla(135, 73%, 55%, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
pre.diff-highlight > code .token.token.inserted:not(.prefix)::selection,
|
|
||||||
pre.diff-highlight > code .token.token.inserted:not(.prefix) *::selection,
|
|
||||||
pre > code.diff-highlight .token.token.inserted:not(.prefix)::selection,
|
|
||||||
pre > code.diff-highlight .token.token.inserted:not(.prefix) *::selection {
|
|
||||||
background-color: hsla(135, 73%, 55%, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Previewers plugin overrides */
|
|
||||||
/* Based on https://github.com/atom-community/atom-ide-datatip/blob/master/styles/atom-ide-datatips.less and https://github.com/atom/atom/blob/master/packages/one-dark-ui */
|
|
||||||
/* Border around popup */
|
|
||||||
.prism-previewer.prism-previewer:before,
|
|
||||||
.prism-previewer-gradient.prism-previewer-gradient div {
|
|
||||||
border-color: hsl(224, 13%, 17%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Angle and time should remain as circles and are hence not included */
|
|
||||||
.prism-previewer-color.prism-previewer-color:before,
|
|
||||||
.prism-previewer-gradient.prism-previewer-gradient div,
|
|
||||||
.prism-previewer-easing.prism-previewer-easing:before {
|
|
||||||
border-radius: 0.3em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Triangles pointing to the code */
|
|
||||||
.prism-previewer.prism-previewer:after {
|
|
||||||
border-top-color: hsl(224, 13%, 17%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.prism-previewer-flipped.prism-previewer-flipped.after {
|
|
||||||
border-bottom-color: hsl(224, 13%, 17%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Background colour within the popup */
|
|
||||||
.prism-previewer-angle.prism-previewer-angle:before,
|
|
||||||
.prism-previewer-time.prism-previewer-time:before,
|
|
||||||
.prism-previewer-easing.prism-previewer-easing {
|
|
||||||
background: hsl(219, 13%, 22%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* For angle, this is the positive area (eg. 90deg will display one quadrant in this colour) */
|
|
||||||
/* For time, this is the alternate colour */
|
|
||||||
.prism-previewer-angle.prism-previewer-angle circle,
|
|
||||||
.prism-previewer-time.prism-previewer-time circle {
|
|
||||||
stroke: hsl(220, 14%, 71%);
|
|
||||||
stroke-opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Stroke colours of the handle, direction point, and vector itself */
|
|
||||||
.prism-previewer-easing.prism-previewer-easing circle,
|
|
||||||
.prism-previewer-easing.prism-previewer-easing path,
|
|
||||||
.prism-previewer-easing.prism-previewer-easing line {
|
|
||||||
stroke: hsl(220, 14%, 71%);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Fill colour of the handle */
|
|
||||||
.prism-previewer-easing.prism-previewer-easing circle {
|
|
||||||
fill: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.prose pre {
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Or more aggressively */
|
|
||||||
.prose pre code {
|
|
||||||
contain: layout style paint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* messaging-style typing indicator animation */
|
/* messaging-style typing indicator animation */
|
||||||
@keyframes typing {
|
@keyframes typing {
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { remark } from "remark";
|
|
||||||
import remarkStringify from "remark-stringify";
|
|
||||||
import remarkStreamingMarkdown from "./remarkStreamingMarkdown";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Process markdown content for streaming display using the remark plugin.
|
|
||||||
* This is primarily used for testing the remark plugin with string inputs/outputs.
|
|
||||||
*/
|
|
||||||
export function processStreamingMarkdown(content: string): string {
|
|
||||||
if (!content) return content;
|
|
||||||
|
|
||||||
const result = remark()
|
|
||||||
.use(remarkStreamingMarkdown, { debug: false })
|
|
||||||
.use(remarkStringify)
|
|
||||||
.processSync(content);
|
|
||||||
|
|
||||||
// remove trailing newline to keep tests cleaner
|
|
||||||
let output = result.toString();
|
|
||||||
if (output.endsWith("\n")) {
|
|
||||||
output = output.slice(0, -1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
@@ -1,447 +0,0 @@
|
|||||||
import { parents, type Proxy } from "unist-util-parents";
|
|
||||||
import type { Plugin } from "unified";
|
|
||||||
import type {
|
|
||||||
Emphasis,
|
|
||||||
Node,
|
|
||||||
Parent,
|
|
||||||
Root,
|
|
||||||
RootContent,
|
|
||||||
Text,
|
|
||||||
Strong,
|
|
||||||
PhrasingContent,
|
|
||||||
Paragraph,
|
|
||||||
} from "mdast";
|
|
||||||
import { u } from "unist-builder";
|
|
||||||
|
|
||||||
declare module "unist" {
|
|
||||||
interface Node {
|
|
||||||
/** Added by `unist-util-parents` (or your own walk). */
|
|
||||||
parent?: Proxy & Parent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// interface SimpleTextRule {
|
|
||||||
// pattern: RegExp;
|
|
||||||
// transform: (matches: RegExpExecArray[], lastNode: Proxy) => void;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const simpleTextRules: SimpleTextRule[] = [
|
|
||||||
// // TODO(drifkin): generalize this for `__`/`_`/`~~`/`~` etc.
|
|
||||||
// {
|
|
||||||
// pattern: /(\*\*)(?=\S|$)/g,
|
|
||||||
// transform: (matchesIterator, lastNode) => {
|
|
||||||
// const textNode = lastNode.node as Text;
|
|
||||||
|
|
||||||
// const matches = [...matchesIterator];
|
|
||||||
// const lastMatch = matches[matches.length - 1];
|
|
||||||
// const origValue = textNode.value;
|
|
||||||
// const start = lastMatch.index;
|
|
||||||
// const sep = lastMatch[1];
|
|
||||||
|
|
||||||
// const before = origValue.slice(0, start);
|
|
||||||
// const after = origValue.slice(start + sep.length);
|
|
||||||
|
|
||||||
// if (lastNode.parent) {
|
|
||||||
// const index = (lastNode.parent.node as Parent).children.indexOf(
|
|
||||||
// lastNode.node as RootContent,
|
|
||||||
// );
|
|
||||||
// const shouldRemove = before.length === 0;
|
|
||||||
// if (!shouldRemove) {
|
|
||||||
// textNode.value = before;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// const newNode = u("strong", {
|
|
||||||
// children: [u("text", { value: after })],
|
|
||||||
// });
|
|
||||||
// (lastNode.parent.node as Parent).children.splice(
|
|
||||||
// index + (shouldRemove ? 0 : 1),
|
|
||||||
// shouldRemove ? 1 : 0,
|
|
||||||
// newNode,
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// ];
|
|
||||||
|
|
||||||
interface Options {
|
|
||||||
debug?: boolean;
|
|
||||||
onLastNode?: (info: LastNodeInfo) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LastNodeInfo {
|
|
||||||
path: string[];
|
|
||||||
type: string;
|
|
||||||
value?: string;
|
|
||||||
lastChars?: string;
|
|
||||||
fullNode: Node;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes `child` from `parent` in-place.
|
|
||||||
* @returns `true` if the child was found and removed; `false` otherwise.
|
|
||||||
*/
|
|
||||||
export function removeChildFromParent(
|
|
||||||
child: RootContent,
|
|
||||||
parent: Node,
|
|
||||||
): boolean {
|
|
||||||
if (!isParent(parent)) return false; // parent isn’t a Parent → nothing to do
|
|
||||||
|
|
||||||
const idx = parent.children.indexOf(child);
|
|
||||||
if (idx < 0) return false; // not a child → nothing to remove
|
|
||||||
|
|
||||||
parent.children.splice(idx, 1);
|
|
||||||
return true; // removal successful
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Narrow a generic `Node` to a `Parent` (i.e. one that really has children). */
|
|
||||||
function isParent(node: Node): node is Parent {
|
|
||||||
// A `Parent` always has a `children` array; make sure it's an array first.
|
|
||||||
return Array.isArray((node as Partial<Parent>).children);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Follow “last-child” pointers until you reach a leaf.
|
|
||||||
* Returns the right-most, deepest node in source order.
|
|
||||||
*/
|
|
||||||
export function findRightmostDeepestNode(root: Node): Node {
|
|
||||||
let current: Node = root;
|
|
||||||
|
|
||||||
// While the current node *is* a Parent and has at least one child…
|
|
||||||
while (isParent(current) && current.children.length > 0) {
|
|
||||||
const lastIndex = current.children.length - 1;
|
|
||||||
current = current.children[lastIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
return current; // Leaf: no further children
|
|
||||||
}
|
|
||||||
|
|
||||||
const remarkStreamingMarkdown: Plugin<[Options?], Root> = () => {
|
|
||||||
return (tree) => {
|
|
||||||
const treeWithParents = parents(tree);
|
|
||||||
const lastNode = findRightmostDeepestNode(treeWithParents) as Proxy;
|
|
||||||
|
|
||||||
const parentNode = lastNode.parent;
|
|
||||||
const grandparentNode = parentNode?.parent;
|
|
||||||
|
|
||||||
let ruleMatched = false;
|
|
||||||
|
|
||||||
// handling `* *` -> ``
|
|
||||||
//
|
|
||||||
// if the last node is part of a <list item (otherwise empty)> ->
|
|
||||||
// <list (otherwise empty)> -> <list item (last node, empty)>, then we need to
|
|
||||||
// remove everything up to and including the first list item. This happens
|
|
||||||
// when we have `* *`, which can become a bolded list item OR a horizontal
|
|
||||||
// line
|
|
||||||
if (
|
|
||||||
lastNode.type === "listItem" &&
|
|
||||||
parentNode &&
|
|
||||||
grandparentNode &&
|
|
||||||
parentNode.type === "list" &&
|
|
||||||
grandparentNode.type === "listItem" &&
|
|
||||||
parentNode.children.length === 1 &&
|
|
||||||
grandparentNode.children.length === 1
|
|
||||||
) {
|
|
||||||
ruleMatched = true;
|
|
||||||
if (grandparentNode.parent) {
|
|
||||||
removeChildFromParent(
|
|
||||||
grandparentNode.node as RootContent,
|
|
||||||
grandparentNode.parent.node,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Handle `*` -> ``:
|
|
||||||
//
|
|
||||||
// if the last node is just an empty list item, we need to remove it
|
|
||||||
// because it could become something else (e.g., a horizontal line)
|
|
||||||
} else if (
|
|
||||||
lastNode.type === "listItem" &&
|
|
||||||
parentNode &&
|
|
||||||
parentNode.type === "list"
|
|
||||||
) {
|
|
||||||
ruleMatched = true;
|
|
||||||
removeChildFromParent(lastNode.node as RootContent, parentNode.node);
|
|
||||||
} else if (lastNode.type === "thematicBreak") {
|
|
||||||
ruleMatched = true;
|
|
||||||
const parent = lastNode.parent;
|
|
||||||
if (parent) {
|
|
||||||
removeChildFromParent(lastNode.node as RootContent, parent.node);
|
|
||||||
}
|
|
||||||
} else if (lastNode.type === "text") {
|
|
||||||
const textNode = lastNode.node as Text;
|
|
||||||
if (textNode.value.endsWith("**")) {
|
|
||||||
ruleMatched = true;
|
|
||||||
textNode.value = textNode.value.slice(0, -2);
|
|
||||||
// if there's a newline then a number, this is very very likely a
|
|
||||||
// numbered list item. Let's just hide it until the period comes (or
|
|
||||||
// other text disambiguates it)
|
|
||||||
} else {
|
|
||||||
const match = textNode.value.match(/^([0-9]+)$/m);
|
|
||||||
if (match) {
|
|
||||||
const number = match[1];
|
|
||||||
textNode.value = textNode.value.slice(0, -number.length - 1);
|
|
||||||
ruleMatched = true;
|
|
||||||
// if the text node is now empty, then we might want to remove other
|
|
||||||
// elements, like a now-empty containing paragraph, or a break that
|
|
||||||
// might disappear once more tokens come in
|
|
||||||
if (textNode.value.length === 0) {
|
|
||||||
if (
|
|
||||||
lastNode.parent?.type === "paragraph" &&
|
|
||||||
lastNode.parent.children.length === 1
|
|
||||||
) {
|
|
||||||
// remove the whole paragraph if it's now empty (otherwise it'll
|
|
||||||
// cause an extra newline that might not last)
|
|
||||||
removeChildFromParent(
|
|
||||||
lastNode.parent.node as Paragraph,
|
|
||||||
lastNode.parent.parent?.node as Node,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const prev = prevSibling(lastNode);
|
|
||||||
if (prev?.type === "break") {
|
|
||||||
removeChildFromParent(
|
|
||||||
prev.node as RootContent,
|
|
||||||
lastNode.parent?.node as Node,
|
|
||||||
);
|
|
||||||
removeChildFromParent(
|
|
||||||
lastNode.node as RootContent,
|
|
||||||
lastNode.parent?.node as Node,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ruleMatched) {
|
|
||||||
return tree;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we need to
|
|
||||||
// a case like
|
|
||||||
// - *def `abc` [abc **def**](abc)*
|
|
||||||
// is pretty tricky, because if we land just after def, then we actually
|
|
||||||
// have two separate tags to process at two different parents. Maybe we
|
|
||||||
// need to keep iterating up until we find a paragraph, but process each
|
|
||||||
// parent on the way up. Hmm, well actually after `def` we won't even be a proper link yet
|
|
||||||
// TODO(drifkin): it's really if the last node's parent is a paragraph, for which the following is a sub-cas where the lastNode is a text node.
|
|
||||||
// And instead of just processing simple text rules, they need to operate on the whole paragraph
|
|
||||||
// like `**[abc](def)` needs to become `**[abc](def)**`
|
|
||||||
|
|
||||||
// if we're just text at the end, then we should remove some ambiguous characters
|
|
||||||
|
|
||||||
if (lastNode.parent) {
|
|
||||||
const didChange = processParent(lastNode.parent as Parent & Proxy);
|
|
||||||
if (didChange) {
|
|
||||||
// TODO(drifkin): need to fix up the tree, but not sure lastNode will still exist? Check all the transforms to see if it's safe to find the last node again
|
|
||||||
//
|
|
||||||
// need to regen the tree w/ parents since reparenting could've happened
|
|
||||||
// treeWithParents = parents(tree);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const grandparent = lastNode.parent?.parent;
|
|
||||||
// TODO(drifkin): let's go arbitrarily high up the tree, but limiting it
|
|
||||||
// to 2 levels for now until I think more about the stop condition
|
|
||||||
if (grandparent) {
|
|
||||||
processParent(grandparent as Parent & Proxy);
|
|
||||||
}
|
|
||||||
|
|
||||||
// console.log("ruleMatched", ruleMatched);
|
|
||||||
|
|
||||||
// } else if (lastNode.parent?.type === "paragraph") {
|
|
||||||
// console.log("!!! paragraph");
|
|
||||||
// console.log("lastNode.parent", lastNode.parent);
|
|
||||||
|
|
||||||
// // Handle `**abc*` -> `**abc**`:
|
|
||||||
// // We detect this when the last child is an emphasis node, and it's preceded by a text node that ends with `*`
|
|
||||||
// const paragraph = lastNode.parent as Proxy & Paragraph;
|
|
||||||
// if (paragraph.children.length >= 2) {
|
|
||||||
// const lastChild = paragraph.children[paragraph.children.length - 1];
|
|
||||||
// if (lastChild.type === "emphasis") {
|
|
||||||
// const sibling = paragraph.children[paragraph.children.length - 2];
|
|
||||||
// if (sibling.type === "text") {
|
|
||||||
// const siblingText = sibling as Text & Proxy;
|
|
||||||
// if (siblingText.value.endsWith("*")) {
|
|
||||||
// ruleMatched = true;
|
|
||||||
// const textNode = (lastNode as Proxy).node as Text;
|
|
||||||
// textNode.value = textNode.value.slice(0, -1);
|
|
||||||
// paragraph.node.type = "strong";
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } else if (lastNode.type === "text") {
|
|
||||||
// // Handle `**abc*` -> `**abc**`:
|
|
||||||
// //
|
|
||||||
// // this gets parsed as a text node ending in `*` followed by an emphasis
|
|
||||||
// // node. So if we're in text, we need to check if our parent is emphasis,
|
|
||||||
// // and then get our parent's sibling before it and check if it ends with
|
|
||||||
// // `*`
|
|
||||||
// const parent = lastNode.parent;
|
|
||||||
// if (parent && parent.type === "emphasis") {
|
|
||||||
// const grandparent = parent.parent;
|
|
||||||
// if (grandparent) {
|
|
||||||
// const index = (grandparent.node as Parent).children.indexOf(
|
|
||||||
// parent.node as RootContent,
|
|
||||||
// );
|
|
||||||
// if (index > 0) {
|
|
||||||
// const prevNode = grandparent.children[index - 1];
|
|
||||||
// if (
|
|
||||||
// prevNode.type === "text" &&
|
|
||||||
// (prevNode as Text).value.endsWith("*")
|
|
||||||
// ) {
|
|
||||||
// ruleMatched = true;
|
|
||||||
// const textNode = (prevNode as Proxy).node as Text;
|
|
||||||
// textNode.value = textNode.value.slice(0, -1);
|
|
||||||
// parent.node.type = "strong";
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (!ruleMatched) {
|
|
||||||
// // if the last node is just text, then we process it in order to fix up certain unclosed items
|
|
||||||
// // e.g., `**abc` -> `**abc**`
|
|
||||||
// const textNode = lastNode.node as Text;
|
|
||||||
// for (const rule of simpleTextRules) {
|
|
||||||
// const matchesIterator = textNode.value.matchAll(rule.pattern);
|
|
||||||
// const matches = [...matchesIterator];
|
|
||||||
// if (matches.length > 0) {
|
|
||||||
// rule.transform(matches, lastNode);
|
|
||||||
// ruleMatched = true;
|
|
||||||
// break;
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// } else if (!ruleMatched) {
|
|
||||||
// // console.log("no rule matched", lastNode);
|
|
||||||
// }
|
|
||||||
|
|
||||||
return tree;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
function processParent(parent: Parent & Proxy): boolean {
|
|
||||||
if (parent.type === "emphasis") {
|
|
||||||
// Handle `**abc*` -> `**abc**`:
|
|
||||||
// We detect this when we end with an emphasis node, and it's preceded by
|
|
||||||
// a text node that ends with `*`
|
|
||||||
// TODO(drifkin): the last node can be more deeply nested (e.g., a code
|
|
||||||
// literal in a link), so we probably need to walk up the tree until we
|
|
||||||
// find an emphasis node or a block? For now we'll just go up one layer to
|
|
||||||
// catch the most common cases
|
|
||||||
const emphasisNode = parent as Emphasis & Proxy;
|
|
||||||
const grandparent = emphasisNode.parent;
|
|
||||||
if (grandparent) {
|
|
||||||
const indexOfEmphasisNode = (grandparent.node as Parent).children.indexOf(
|
|
||||||
emphasisNode.node as RootContent,
|
|
||||||
);
|
|
||||||
if (indexOfEmphasisNode >= 0) {
|
|
||||||
const nodeBefore = grandparent.children[indexOfEmphasisNode - 1] as
|
|
||||||
| (Node & Proxy)
|
|
||||||
| undefined;
|
|
||||||
if (nodeBefore?.type === "text") {
|
|
||||||
const textNode = nodeBefore.node as Text;
|
|
||||||
if (textNode.value.endsWith("*")) {
|
|
||||||
const strBefore = textNode.value.slice(0, -1);
|
|
||||||
textNode.value = strBefore;
|
|
||||||
const strongNode = u("strong", {
|
|
||||||
children: emphasisNode.children,
|
|
||||||
});
|
|
||||||
(grandparent.node as Parent).children.splice(
|
|
||||||
indexOfEmphasisNode,
|
|
||||||
1,
|
|
||||||
strongNode,
|
|
||||||
);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Let's check if we have any bold items to close
|
|
||||||
for (let i = parent.children.length - 1; i >= 0; i--) {
|
|
||||||
const child = parent.children[i];
|
|
||||||
if (child.type === "text") {
|
|
||||||
const textNode = child as Text & Proxy;
|
|
||||||
const sep = "**";
|
|
||||||
const index = textNode.value.lastIndexOf(sep);
|
|
||||||
if (index >= 0) {
|
|
||||||
let isValidOpening = false;
|
|
||||||
if (index + sep.length < textNode.value.length) {
|
|
||||||
const charAfter = textNode.value[index + sep.length];
|
|
||||||
if (!isWhitespace(charAfter)) {
|
|
||||||
isValidOpening = true;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (i < parent.children.length - 1) {
|
|
||||||
// TODO(drifkin): I'm not sure that this check is strict enough.
|
|
||||||
// We're trying to detect cases like `**[abc]()` where the char
|
|
||||||
// after the opening ** is indeed a non-whitespace character. We're
|
|
||||||
// using the heuristic that there's another item after the current
|
|
||||||
// one, but I'm not sure if that is good enough. In a well
|
|
||||||
// constructed tree, there aren't two text nodes in a row, so this
|
|
||||||
// _seems_ good, but I should think through it more
|
|
||||||
isValidOpening = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isValidOpening) {
|
|
||||||
// TODO(drifkin): close the bold
|
|
||||||
const strBefore = textNode.value.slice(0, index);
|
|
||||||
const strAfter = textNode.value.slice(index + sep.length);
|
|
||||||
(textNode.node as Text).value = strBefore;
|
|
||||||
// TODO(drifkin): the node above could be empty in which case we probably want to delete it
|
|
||||||
const children: PhrasingContent[] = [
|
|
||||||
...(strAfter.length > 0 ? [u("text", { value: strAfter })] : []),
|
|
||||||
];
|
|
||||||
const strongNode: Strong = u("strong", {
|
|
||||||
children,
|
|
||||||
});
|
|
||||||
const nodesAfter = (parent.node as Parent).children.splice(
|
|
||||||
i + 1,
|
|
||||||
parent.children.length - i - 1,
|
|
||||||
strongNode,
|
|
||||||
);
|
|
||||||
// TODO(drifkin): this cast seems iffy, should see if we can cast the
|
|
||||||
// parent instead, which would also help us check some of our
|
|
||||||
// assumptions
|
|
||||||
strongNode.children.push(...(nodesAfter as PhrasingContent[]));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function prevSibling(node: Node & Proxy): (Node & Proxy) | null {
|
|
||||||
const parent = node.parent;
|
|
||||||
if (parent) {
|
|
||||||
const index = parent.children.indexOf(node);
|
|
||||||
return parent.children[index - 1] as Node & Proxy;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isWhitespace(str: string) {
|
|
||||||
return str.trim() === "";
|
|
||||||
}
|
|
||||||
|
|
||||||
// function debugPrintTreeNoPos(tree: Node) {
|
|
||||||
// console.log(
|
|
||||||
// JSON.stringify(
|
|
||||||
// tree,
|
|
||||||
// (key, value) => {
|
|
||||||
// if (key === "position") {
|
|
||||||
// return undefined;
|
|
||||||
// }
|
|
||||||
// return value;
|
|
||||||
// },
|
|
||||||
// 2,
|
|
||||||
// ),
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
export default remarkStreamingMarkdown;
|
|
||||||
Reference in New Issue
Block a user