chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s
docmd CI verification / verify (push) Failing after 0s
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025-present docmd.io
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,19 @@
|
||||
# @docmd/plugin-threads
|
||||
|
||||
Adds inline discussion threads to your docmd site - stored directly in Markdown as `::: threads` containers, so comments live in your repo and sync with your Git and PR workflows. Supports text highlighting, replies, thread resolution, and emoji reactions. An optional plugin, installed separately.
|
||||
|
||||
```bash
|
||||
docmd add threads
|
||||
```
|
||||
|
||||
Original author: [@svallory](https://github.com/svallory)
|
||||
|
||||
Part of the **[docmd](https://github.com/docmd-io/docmd)** documentation engine.
|
||||
|
||||
## Documentation
|
||||
|
||||
See **[docs.docmd.io](https://docs.docmd.io)** for full usage and API reference.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,31 @@
|
||||
import esbuild from 'esbuild';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
async function build() {
|
||||
await esbuild.build({
|
||||
entryPoints: [path.resolve(__dirname, 'src/client/index.ts')],
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
target: 'es2022',
|
||||
format: 'esm',
|
||||
outdir: path.resolve(__dirname, 'dist/client'),
|
||||
minify: false,
|
||||
sourcemap: 'inline',
|
||||
loader: { '.css': 'css' },
|
||||
tsconfigRaw: JSON.stringify({
|
||||
compilerOptions: {
|
||||
experimentalDecorators: true,
|
||||
useDefineForClassFields: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
console.log('Client built to dist/client/');
|
||||
}
|
||||
|
||||
build().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Basic UI tests - adapted from old devtalk.e2e.spec.ts "basic UI" describe block.
|
||||
*
|
||||
* Changes from old plugin:
|
||||
* - No sidebar panel/toggle button (inline-only architecture)
|
||||
* - threads-app replaces devtalk-app as root element
|
||||
* - "New Thread" button replaces sidebar toggle
|
||||
* - Threads rendered server-side with .threads-* classes
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Actor, NavigateTo } from "./screenplay.ts";
|
||||
import { setAuthor, waitForClientProcessing } from "./helpers.ts";
|
||||
|
||||
|
||||
|
||||
let user: Actor;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
user = new Actor("TestUser", page);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
await setAuthor(page, "TestUser");
|
||||
// NOTE: Do NOT clean threads here - these tests verify the pre-existing
|
||||
// threads from the playground markdown (index.md ::: threads block)
|
||||
// Wait for client-side JS to finish processing highlights
|
||||
await waitForClientProcessing(page);
|
||||
});
|
||||
|
||||
test.describe("threads plugin - basic UI", () => {
|
||||
test("loads on the page with threads-app element", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const app = user.page.locator("threads-app");
|
||||
await expect(app).toBeAttached();
|
||||
});
|
||||
|
||||
test("New Thread button is injected into the content area", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const btn = user.page.locator(".threads-new-thread-btn").first();
|
||||
await expect(btn).toBeVisible({ timeout: 5_000 });
|
||||
await expect(btn).toContainText("New Thread");
|
||||
});
|
||||
|
||||
test("server-rendered threads appear with correct class structure", async () => {
|
||||
// The playground index.md has pre-existing threads in the ::: threads block
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
// There should be thread cards rendered from the markdown
|
||||
const threads = user.page.locator(".threads-thread[data-thread-id]");
|
||||
const count = await threads.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
|
||||
// Each thread should have at least one comment
|
||||
const firstThread = threads.first();
|
||||
const comments = firstThread.locator(".threads-comment");
|
||||
await expect(comments.first()).toBeAttached();
|
||||
});
|
||||
|
||||
test("highlights appear on marked text", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
// The playground has ==highlighted text=={thread-id} in the markdown
|
||||
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
|
||||
const count = await highlights.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("highlights have cycling colors (not all the same)", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
await waitForClientProcessing(user.page);
|
||||
|
||||
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
|
||||
const count = await highlights.count();
|
||||
|
||||
if (count < 2) {
|
||||
test.skip(true, "Need at least 2 highlights to test color cycling");
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all highlight color classes
|
||||
const colorClasses: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const classList = await highlights.nth(i).evaluate((el) =>
|
||||
Array.from(el.classList).filter((c) => c.startsWith("threads-hl-")),
|
||||
);
|
||||
colorClasses.push(...classList);
|
||||
}
|
||||
|
||||
// With 2+ highlights, we should see at least 2 different colors
|
||||
const uniqueColors = new Set(colorClasses);
|
||||
expect(uniqueColors.size).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
test("thread cards are placed inline after their highlighted block", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
await waitForClientProcessing(user.page);
|
||||
|
||||
// Find a highlight mark that has a corresponding thread card
|
||||
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
|
||||
const firstHighlight = highlights.first();
|
||||
|
||||
if ((await firstHighlight.count()) === 0) {
|
||||
test.skip(true, "No highlights found on the page");
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = await firstHighlight.getAttribute("data-thread-id");
|
||||
if (!threadId) return;
|
||||
|
||||
const threadCard = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
await expect(threadCard).toBeAttached();
|
||||
|
||||
// Thread card should appear after the highlight's parent block in document order
|
||||
const isAfterHighlight = await user.page.evaluate(
|
||||
({ threadId }) => {
|
||||
const mark = document.querySelector(`mark.threads-highlight[data-thread-id="${threadId}"]`);
|
||||
const thread = document.querySelector(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
if (!mark || !thread) return false;
|
||||
return mark.compareDocumentPosition(thread) & Node.DOCUMENT_POSITION_FOLLOWING;
|
||||
},
|
||||
{ threadId },
|
||||
);
|
||||
expect(isAfterHighlight).toBeTruthy();
|
||||
});
|
||||
|
||||
test("thread cards have matching border colors to their highlights", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
await waitForClientProcessing(user.page);
|
||||
|
||||
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
|
||||
const firstHighlight = highlights.first();
|
||||
|
||||
if ((await firstHighlight.count()) === 0) {
|
||||
test.skip(true, "No highlights found");
|
||||
return;
|
||||
}
|
||||
|
||||
const threadId = await firstHighlight.getAttribute("data-thread-id");
|
||||
if (!threadId) return;
|
||||
|
||||
// Get the highlight's color class
|
||||
const hlColorClass = await firstHighlight.evaluate((el) =>
|
||||
Array.from(el.classList).find((c) => c.startsWith("threads-hl-")),
|
||||
);
|
||||
|
||||
if (!hlColorClass) return;
|
||||
|
||||
// The thread card should have the matching border class
|
||||
const expectedBorderClass = hlColorClass.replace("threads-hl-", "threads-border-");
|
||||
const threadCard = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
const hasBorderClass = await threadCard.evaluate(
|
||||
(el, cls) => el.classList.contains(cls),
|
||||
expectedBorderClass,
|
||||
);
|
||||
expect(hasBorderClass).toBe(true);
|
||||
});
|
||||
|
||||
test("clicking a highlight scrolls to and flashes its thread", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
await waitForClientProcessing(user.page);
|
||||
|
||||
const highlights = user.page.locator("mark.threads-highlight[data-thread-id]");
|
||||
const firstHighlight = highlights.first();
|
||||
|
||||
if ((await firstHighlight.count()) === 0) {
|
||||
test.skip(true, "No highlights found");
|
||||
return;
|
||||
}
|
||||
|
||||
await firstHighlight.click();
|
||||
|
||||
// After clicking, the thread card should receive the flash class
|
||||
const threadId = await firstHighlight.getAttribute("data-thread-id");
|
||||
const threadCard = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
await expect(threadCard).toHaveClass(/threads-thread--flash/, { timeout: 2_000 });
|
||||
});
|
||||
|
||||
test("popover hidden initially without text selection", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
// Without any text selection, the popover should not show
|
||||
await expect(
|
||||
user.page.locator("threads-popover").getByText("Add comment"),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("reply buttons present on thread cards", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
const threads = user.page.locator(".threads-thread[data-thread-id]");
|
||||
const count = await threads.count();
|
||||
|
||||
if (count === 0) {
|
||||
test.skip(true, "No threads on page");
|
||||
return;
|
||||
}
|
||||
|
||||
// Each thread should have a Reply button
|
||||
for (let i = 0; i < count; i++) {
|
||||
const thread = threads.nth(i);
|
||||
const replyBtn = thread.locator(".threads-comment-reply-btn").first();
|
||||
await expect(replyBtn).toBeAttached();
|
||||
await expect(replyBtn).toContainText("Reply");
|
||||
}
|
||||
});
|
||||
|
||||
test("threads-sidebar container is hidden", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
const sidebar = user.page.locator(".threads-sidebar");
|
||||
if ((await sidebar.count()) > 0) {
|
||||
// The sidebar wrapper should be hidden (display: none)
|
||||
await expect(sidebar).toBeHidden();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Comment display tests - adapted from old comment-display.e2e.spec.ts.
|
||||
*
|
||||
* Changes from old plugin:
|
||||
* - Comments are server-rendered HTML (.threads-comment) not Lit components
|
||||
* - Server-rendered comments have .threads-comment__meta and .threads-comment__body
|
||||
* - Author info comes from data-author attribute on .threads-comment element
|
||||
* - Date comes from data-date attribute
|
||||
* - No "just now" time formatting (server renders static date strings like "2026-03-09")
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Actor } from "./screenplay.ts";
|
||||
import { cleanThreads, setAuthor, seedAndReload, waitForReload } from "./helpers.ts";
|
||||
|
||||
let user: Actor;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
user = new Actor("TestUser", page);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
await setAuthor(page, "TestUser");
|
||||
await cleanThreads(page);
|
||||
await waitForReload(page);
|
||||
});
|
||||
|
||||
test.describe("comment display", () => {
|
||||
test("author name is displayed on the comment", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
author: "Alice",
|
||||
body: "Hello from Alice",
|
||||
});
|
||||
|
||||
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
await expect(thread).toBeAttached({ timeout: 5_000 });
|
||||
|
||||
// Server-rendered comment should show the author
|
||||
const meta = thread.locator(".threads-comment__meta").first();
|
||||
await expect(meta).toContainText("Alice");
|
||||
});
|
||||
|
||||
test("comment body is displayed", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
author: "TestUser",
|
||||
body: "This is the comment body text",
|
||||
});
|
||||
|
||||
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
await expect(thread).toBeAttached({ timeout: 5_000 });
|
||||
|
||||
const body = thread.locator(".threads-comment__body").first();
|
||||
await expect(body).toContainText("This is the comment body text");
|
||||
});
|
||||
|
||||
test("date is displayed on the comment", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
author: "TestUser",
|
||||
body: "Date display test",
|
||||
});
|
||||
|
||||
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
await expect(thread).toBeAttached({ timeout: 5_000 });
|
||||
|
||||
// Server-rendered meta should contain a date (format: YYYY-MM-DD)
|
||||
const meta = thread.locator(".threads-comment__meta").first();
|
||||
const metaText = await meta.textContent();
|
||||
// Should contain a date pattern like 2026-03-09
|
||||
expect(metaText).toMatch(/\d{4}-\d{2}-\d{2}/);
|
||||
});
|
||||
|
||||
test("comment has data-author attribute", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
author: "Bob",
|
||||
body: "Attribute test",
|
||||
});
|
||||
|
||||
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
const comment = thread.locator(".threads-comment").first();
|
||||
await expect(comment).toHaveAttribute("data-author", "Bob");
|
||||
});
|
||||
|
||||
test("multiple comments displayed in order", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
author: "Alice",
|
||||
body: "First comment",
|
||||
replies: [
|
||||
{ author: "Bob", body: "Second comment" },
|
||||
{ author: "Charlie", body: "Third comment" },
|
||||
],
|
||||
});
|
||||
|
||||
let thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
await expect(thread).toBeAttached({ timeout: 5_000 });
|
||||
|
||||
// The dev server may need an additional reload to pick up the last reply
|
||||
let comments = thread.locator(".threads-comment");
|
||||
if ((await comments.count()) < 3) {
|
||||
await waitForReload(user.page);
|
||||
thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
comments = thread.locator(".threads-comment");
|
||||
}
|
||||
await expect(comments).toHaveCount(3);
|
||||
|
||||
await expect(comments.nth(0).locator(".threads-comment__body")).toContainText("First comment");
|
||||
await expect(comments.nth(1).locator(".threads-comment__body")).toContainText("Second comment");
|
||||
await expect(comments.nth(2).locator(".threads-comment__body")).toContainText("Third comment");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("markdown rendering in comments", () => {
|
||||
test("comment body renders inline markdown from markdown-it", async () => {
|
||||
// Server-rendered comments go through markdown-it, which handles bold/italic/code
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
body: "Use `console.log()` for debugging",
|
||||
});
|
||||
|
||||
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
const body = thread.locator(".threads-comment__body").first();
|
||||
await expect(body).toBeAttached({ timeout: 5_000 });
|
||||
|
||||
// markdown-it should render backtick as <code>
|
||||
const codeEl = body.locator("code");
|
||||
await expect(codeEl).toBeAttached();
|
||||
});
|
||||
|
||||
test("HTML special characters are escaped", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
body: "<script>alert('xss')</script>",
|
||||
});
|
||||
|
||||
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
const body = thread.locator(".threads-comment__body").first();
|
||||
await expect(body).toBeAttached({ timeout: 5_000 });
|
||||
|
||||
// No live <script> element must exist inside the comment body
|
||||
await expect(body.locator("script")).toHaveCount(0);
|
||||
|
||||
// The raw innerHTML must contain escaped entities
|
||||
const rawHtml = await body.evaluate((el) => el.innerHTML);
|
||||
expect(rawHtml).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
title: "docmd _playground"
|
||||
description: "A testing ground for docmd core engine features."
|
||||
---
|
||||
|
||||
This playground is used to verify core engine changes in real-time. Use this to ensure your modifications to the Markdown parser or UI components behave as expected.
|
||||
|
||||
::: callout tip "How to work here"
|
||||
To test your changes, keep the dev server running (`pnpm run dev`). Any change you make to `packages/core` or `packages/parser` will trigger a hot-reload here instantly.
|
||||
:::
|
||||
|
||||
## Component Verification
|
||||
|
||||
Test your UI components and parser rules here to ensure visual consistency:
|
||||
|
||||
::: card "Container Test"
|
||||
Test nested callouts and containers.
|
||||
|
||||
::: callout warning "Warning"
|
||||
Ensure nested items render correctly.
|
||||
:::
|
||||
:::
|
||||
|
||||
::: tabs
|
||||
== tab "Feature A"
|
||||
### Feature A
|
||||
Verification content.
|
||||
== tab "Feature B"
|
||||
### Feature B
|
||||
Verification content.
|
||||
:::
|
||||
|
||||
## 🔗 Useful Links
|
||||
|
||||
- [Official Documentation](https://docs.docmd.io)
|
||||
- [GitHub Repository](https://github.com/docmd-io/docmd)
|
||||
- [Report an Issue](https://github.com/docmd-io/docmd/issues)
|
||||
|
||||
## 🧪 Developer Checklist
|
||||
- [ ] **Parser:** Does the Markdown output match the HTML in `packages/parser/src/html-renderer.js`?
|
||||
- [ ] **UI:** Does the theme CSS apply to this page correctly?
|
||||
- [ ] **SPA:** Does navigation between pages work without a hard refresh?
|
||||
|
||||
## Threads Plugin Test
|
||||
|
||||
This section tests the ==inline discussion threads=={t-thread1} plugin. You can ==highlight text=={t-thread2} and start discussions.
|
||||
|
||||
Here is another paragraph with a ==different highlight=={t-thread3} to test multiple threads.
|
||||
|
||||
::: threads
|
||||
|
||||
::: thread t-thread1
|
||||
::: comment c1-1 "Alice" "2026-03-01"
|
||||
This is a comment about inline discussion threads.
|
||||
:::
|
||||
::: comment c1-2 "Bob" "2026-03-02"
|
||||
Great point, Alice! I agree this is useful.
|
||||
:::
|
||||
:::
|
||||
|
||||
::: thread t-thread2
|
||||
::: comment c2-1 "Charlie" "2026-03-03"
|
||||
Highlighting text makes it easy to reference specific parts.
|
||||
:::
|
||||
:::
|
||||
|
||||
::: thread t-thread3 resolved "Alice" "2026-03-05"
|
||||
::: comment c3-1 "Alice" "2026-03-04"
|
||||
This highlight tests resolved threads.
|
||||
:::
|
||||
::: comment c3-2 "Bob" "2026-03-05"
|
||||
Resolved! Use `console.log()` for debugging.
|
||||
:::
|
||||
:::
|
||||
|
||||
:::
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Global type declarations for the docmd browser runtime,
|
||||
* used in Playwright page.evaluate() contexts.
|
||||
*/
|
||||
|
||||
declare const docmd: {
|
||||
call: (action: string, payload: Record<string, unknown>) => Promise<any>;
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Helpers for seeding and cleaning threads in E2E tests.
|
||||
*
|
||||
* Unlike the old HTTP API-based approach, we use docmd.call() via
|
||||
* page.evaluate() to interact with the WebSocket action dispatcher.
|
||||
*/
|
||||
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
/** Path to the playground index.md used by the dev server. */
|
||||
const PLAYGROUND_INDEX = path.resolve(
|
||||
__dirname,
|
||||
"../../../_playground/docs/index.md",
|
||||
);
|
||||
|
||||
/** Path to the fixture copy of the original playground markdown. */
|
||||
const FIXTURE_INDEX = path.resolve(__dirname, "fixtures/playground-index.md");
|
||||
|
||||
/**
|
||||
* Restore the playground markdown to its fixture state, removing any
|
||||
* dynamically-added threads/highlights from test runs.
|
||||
*/
|
||||
export async function cleanThreads(_page: Page): Promise<void> {
|
||||
const fixtureContent = fs.readFileSync(FIXTURE_INDEX, "utf-8");
|
||||
fs.writeFileSync(PLAYGROUND_INDEX, fixtureContent, "utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a thread via the WebSocket action dispatcher.
|
||||
* Must be called after the page is loaded and docmd global is available.
|
||||
*
|
||||
* Each WebSocket call writes to the markdown file, which may trigger the
|
||||
* dev server's file watcher to hot-reload the page. To avoid execution
|
||||
* context destruction, we split multi-step operations (add-thread +
|
||||
* add-comment replies) across separate page.evaluate() calls with page
|
||||
* reloads in between.
|
||||
*/
|
||||
export async function seedThread(
|
||||
page: Page,
|
||||
options: {
|
||||
author?: string;
|
||||
body?: string;
|
||||
anchor?: { quote: string } | null;
|
||||
replies?: Array<{ author?: string; body: string }>;
|
||||
resolved?: boolean;
|
||||
} = {},
|
||||
): Promise<{ id: string; comments: Array<{ id: string }> }> {
|
||||
const {
|
||||
author = "TestUser",
|
||||
body = "Seeded comment",
|
||||
anchor = null,
|
||||
replies = [],
|
||||
resolved = false,
|
||||
} = options;
|
||||
|
||||
// Step 1: Create the thread with its first comment
|
||||
const thread = await page.evaluate(
|
||||
async ({ author, body, anchor }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
if (!file) throw new Error("data-source-file not found on body");
|
||||
|
||||
return docmd.call("threads:add-thread", {
|
||||
file,
|
||||
author,
|
||||
body,
|
||||
anchor: anchor
|
||||
? {
|
||||
quote: anchor.quote,
|
||||
prefix: null,
|
||||
suffix: null,
|
||||
selector: null,
|
||||
offset: null,
|
||||
blockText: null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
},
|
||||
{ author, body, anchor },
|
||||
);
|
||||
|
||||
const commentIds = [thread.comments[0].id];
|
||||
|
||||
// Step 2: Add replies one at a time, reloading the page between each
|
||||
// to avoid execution context destruction from hot-reload
|
||||
for (const reply of replies) {
|
||||
await page.waitForTimeout(500);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
|
||||
const comment = await page.evaluate(
|
||||
async ({ file, threadId, replyAuthor, replyBody }) => {
|
||||
const f = file || document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:add-comment", {
|
||||
file: f,
|
||||
threadId,
|
||||
author: replyAuthor,
|
||||
body: replyBody,
|
||||
});
|
||||
},
|
||||
{
|
||||
file: null as string | null,
|
||||
threadId: thread.id,
|
||||
replyAuthor: reply.author ?? "TestUser",
|
||||
replyBody: reply.body,
|
||||
},
|
||||
);
|
||||
commentIds.push(comment.id);
|
||||
}
|
||||
|
||||
// Step 3: Resolve if requested
|
||||
if (resolved) {
|
||||
// Always reload before resolving to ensure the DOM is fresh after the last write
|
||||
// and avoid execution context destruction during the resolve-thread call.
|
||||
await page.waitForTimeout(500);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
|
||||
await page.evaluate(
|
||||
async ({ threadId, resolvedBy }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:resolve-thread", {
|
||||
file,
|
||||
threadId,
|
||||
resolved_by: resolvedBy,
|
||||
});
|
||||
},
|
||||
{ threadId: thread.id, resolvedBy: author },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
id: thread.id,
|
||||
comments: commentIds.map((id) => ({ id })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload the page and wait for the threads-app element to re-attach.
|
||||
* After seeding/cleaning threads via WebSocket actions, the markdown file
|
||||
* on disk has changed. Give the dev server a moment to detect the change,
|
||||
* then reload.
|
||||
*/
|
||||
export async function waitForReload(page: Page): Promise<void> {
|
||||
await page.waitForTimeout(2000);
|
||||
await page.reload({ waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed a thread, reload the page, and wait until the thread element appears
|
||||
* in the server-rendered HTML.
|
||||
*/
|
||||
export async function seedAndReload(
|
||||
page: Page,
|
||||
options: Parameters<typeof seedThread>[1] = {},
|
||||
): Promise<{ id: string; comments: Array<{ id: string }> }> {
|
||||
const result = await seedThread(page, options);
|
||||
|
||||
// The seed action writes to the markdown file. The dev server needs time
|
||||
// to detect the change and re-render. Retry the reload until the thread
|
||||
// appears in the server-rendered HTML (up to 3 attempts).
|
||||
const selector = `.threads-thread[data-thread-id="${result.id}"]`;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
await page.waitForTimeout(attempt === 0 ? 500 : 1500);
|
||||
await page.goto(page.url(), { waitUntil: "domcontentloaded" });
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
|
||||
const found = await page.locator(selector).count();
|
||||
if (found > 0) return result;
|
||||
}
|
||||
|
||||
// Final attempt - let Playwright's own timeout handle it
|
||||
await page.waitForSelector(selector, { state: "attached", timeout: 5_000 });
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the author identity in localStorage.
|
||||
*/
|
||||
export async function setAuthor(page: Page, name: string): Promise<void> {
|
||||
await page.evaluate(
|
||||
(authorName) => localStorage.setItem("threads_author", authorName),
|
||||
name,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the client-side threads-app to finish processing:
|
||||
* scanRenderedHighlights assigns color classes, moves thread cards inline, etc.
|
||||
* We detect completion by waiting for the first highlight to receive a color class.
|
||||
*/
|
||||
export async function waitForClientProcessing(page: Page): Promise<void> {
|
||||
const hasHighlights = await page.locator("mark.threads-highlight[data-thread-id]").count();
|
||||
if (hasHighlights > 0) {
|
||||
// Wait for the first highlight to get a color class (assigned by scanRenderedHighlights)
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const mark = document.querySelector("mark.threads-highlight[data-thread-id]");
|
||||
if (!mark) return true; // No highlights, nothing to wait for
|
||||
return Array.from(mark.classList).some((c) => c.startsWith("threads-hl-"));
|
||||
},
|
||||
{ timeout: 5_000 },
|
||||
);
|
||||
} else {
|
||||
// No highlights on page, just wait a moment for client JS to initialize
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* Inline editor tests - adapted from old inline-editor.e2e.spec.ts.
|
||||
*
|
||||
* Changes from old plugin:
|
||||
* - threads-inline-editor replaces devtalk-inline-editor
|
||||
* - "Add comment" popover replaces "Add to page" button
|
||||
* - No separate panel compose form; all editing is inline
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
Actor,
|
||||
NavigateTo,
|
||||
SelectText,
|
||||
SubmitWithKeyboard,
|
||||
CancelWithKeyboard,
|
||||
TheInlineEditor,
|
||||
} from "./screenplay.ts";
|
||||
import { cleanThreads, setAuthor, waitForReload } from "./helpers.ts";
|
||||
|
||||
const contentSelector = "[data-docmd-content] p, .docmd-content p, article p, main p";
|
||||
|
||||
let user: Actor;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
user = new Actor("TestUser", page);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
await setAuthor(page, "TestUser");
|
||||
await cleanThreads(page);
|
||||
await waitForReload(page);
|
||||
});
|
||||
|
||||
/** Helper: get text to select from the first paragraph */
|
||||
async function getTextToSelect(): Promise<string> {
|
||||
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
|
||||
const firstParagraph = user.page.locator(contentSelector).first();
|
||||
const paragraphText = await firstParagraph.textContent();
|
||||
if (!paragraphText || paragraphText.trim().length < 5) {
|
||||
throw new Error("No suitable text content found to select");
|
||||
}
|
||||
return paragraphText.trim().slice(0, 20);
|
||||
}
|
||||
|
||||
/** Helper: open inline editor by selecting text and clicking "Add comment" */
|
||||
async function openInlineEditor(textToSelect: string): Promise<void> {
|
||||
await user.perform(SelectText.within(contentSelector, textToSelect));
|
||||
|
||||
const addCommentBtn = user.page.locator("threads-popover").getByText("Add comment");
|
||||
await addCommentBtn.waitFor({ state: "visible", timeout: 5_000 });
|
||||
await addCommentBtn.click();
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
await editor.waitFor({ state: "attached", timeout: 5_000 });
|
||||
}
|
||||
|
||||
/** Helper: type text into the inline editor's textarea */
|
||||
async function typeInEditor(text: string): Promise<void> {
|
||||
const textarea = user.page
|
||||
.locator("threads-inline-editor")
|
||||
.locator("wa-textarea")
|
||||
.locator("textarea");
|
||||
await textarea.fill(text);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline editor basics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("inline editor basics", () => {
|
||||
test("shows inline editor after clicking Add comment", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
|
||||
expect(await user.ask(TheInlineEditor.isVisible())).toBe(true);
|
||||
});
|
||||
|
||||
test("submits inline comment and editor disappears", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
await typeInEditor("Inline comment via selection");
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
const submitBtn = editor.locator("wa-button[variant='brand']");
|
||||
await submitBtn.click();
|
||||
|
||||
await editor.waitFor({ state: "detached", timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("cancel removes inline editor", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
expect(await user.ask(TheInlineEditor.isVisible())).toBe(true);
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
const cancelBtn = editor.locator("wa-button[appearance='outlined']");
|
||||
await cancelBtn.click();
|
||||
|
||||
await editor.waitFor({ state: "detached", timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("submit button is disabled when textarea is empty", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
const submitBtn = editor.locator("wa-button[variant='brand']");
|
||||
await expect(submitBtn).toHaveAttribute("disabled", { timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Keyboard shortcuts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("keyboard shortcuts", () => {
|
||||
test("Cmd+Enter submits inline editor", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
await typeInEditor("Submitted via keyboard");
|
||||
|
||||
await user.perform(SubmitWithKeyboard.inInlineEditor());
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
await editor.waitFor({ state: "detached", timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("Escape cancels inline editor", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
|
||||
await user.perform(CancelWithKeyboard.inInlineEditor());
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
await editor.waitFor({ state: "detached", timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Cmd+Enter does nothing when textarea is empty", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
|
||||
// Press Cmd+Enter without typing anything
|
||||
await user.perform(SubmitWithKeyboard.inInlineEditor());
|
||||
|
||||
// Editor should still be present
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
await expect(editor).toBeAttached({ timeout: 1_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline editor validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("inline editor validation", () => {
|
||||
test("whitespace-only input keeps submit button disabled", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
|
||||
const textarea = user.page
|
||||
.locator("threads-inline-editor")
|
||||
.locator("wa-textarea")
|
||||
.locator("textarea");
|
||||
await textarea.fill(" ");
|
||||
|
||||
const submitBtn = user.page
|
||||
.locator("threads-inline-editor")
|
||||
.locator("wa-button[variant='brand']");
|
||||
await expect(submitBtn).toHaveAttribute("disabled");
|
||||
});
|
||||
|
||||
test("inline editor textarea gets autofocus on open", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
|
||||
const textarea = user.page
|
||||
.locator("threads-inline-editor")
|
||||
.locator("wa-textarea")
|
||||
.locator("textarea");
|
||||
|
||||
await expect(textarea).toBeFocused({ timeout: 3_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline editor submitting state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("inline editor submitting state", () => {
|
||||
test("submit button shows 'Submit' text in default state", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
await typeInEditor("Some comment text");
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
const submitBtn = editor.locator("wa-button[variant='brand']");
|
||||
|
||||
await expect(submitBtn).toContainText("Submit");
|
||||
});
|
||||
|
||||
test("submit button footer hint reads Cmd+Enter to submit", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
const hint = editor.locator(".hint");
|
||||
|
||||
await expect(hint).toBeVisible();
|
||||
await expect(hint).toContainText("Cmd+Enter to submit");
|
||||
});
|
||||
|
||||
test("double-submit prevention: clicking submit twice only submits once", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const textToSelect = await getTextToSelect();
|
||||
|
||||
await openInlineEditor(textToSelect);
|
||||
await typeInEditor("Double-submit test comment");
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
const submitBtn = editor.locator("wa-button[variant='brand']");
|
||||
|
||||
await submitBtn.click();
|
||||
await submitBtn.click({ force: true }).catch(() => {});
|
||||
|
||||
await editor.waitFor({ state: "detached", timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inline editor placeholder text
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("inline editor placeholder text", () => {
|
||||
test("inline editor textarea has correct placeholder", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
await openInlineEditor(await getTextToSelect());
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
const textarea = editor.locator("wa-textarea").locator("textarea");
|
||||
|
||||
await expect(textarea).toHaveAttribute("placeholder", "Write your comment...");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* SPA navigation tests - adapted from old headings-navigation.e2e.spec.ts
|
||||
* "SPA navigation" block.
|
||||
*
|
||||
* Changes from old plugin:
|
||||
* - No sidebar panel
|
||||
* - No heading discuss buttons
|
||||
* - Threads are inline, not in a sidebar
|
||||
* - Navigation restores threads by triggering loadThreads + scanRenderedHighlights
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Actor, NavigateTo } from "./screenplay.ts";
|
||||
import { setAuthor, seedAndReload } from "./helpers.ts";
|
||||
|
||||
|
||||
|
||||
let user: Actor;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
user = new Actor("TestUser", page);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
await setAuthor(page, "TestUser");
|
||||
});
|
||||
|
||||
test.describe("SPA navigation", () => {
|
||||
test("threads-app is present after page load", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
const app = user.page.locator("threads-app");
|
||||
await expect(app).toBeAttached();
|
||||
});
|
||||
|
||||
test("New Thread button is reinjected after SPA navigation", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
const btn = user.page.locator(".threads-new-thread-btn").first();
|
||||
await expect(btn).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("threads are page-scoped (pre-existing threads from markdown)", async () => {
|
||||
// The playground index.md has threads defined in markdown
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
const threadsOnIndex = await user.page.locator(".threads-thread[data-thread-id]").count();
|
||||
// The index page has pre-defined threads
|
||||
expect(threadsOnIndex).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* Emoji reaction tests - adapted from old reactions.e2e.spec.ts.
|
||||
*
|
||||
* Changes from old plugin:
|
||||
* - Reactions stored in markdown (not SQLite)
|
||||
* - Server-rendered reactions in .threads-reactions block
|
||||
* - Client-side reactions via threads-comment Lit component (tc-* classes)
|
||||
* - Toggle via docmd.call('threads:toggle-reaction')
|
||||
*
|
||||
* NOTE: Each docmd.call() that modifies the markdown file triggers a hot-reload
|
||||
* via the dev server's file watcher. We must waitForReload() between calls to
|
||||
* avoid "execution context destroyed" errors.
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Actor } from "./screenplay.ts";
|
||||
import { cleanThreads, setAuthor, seedAndReload, waitForReload } from "./helpers.ts";
|
||||
|
||||
let user: Actor;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
user = new Actor("TestUser", page);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
await setAuthor(page, "TestUser");
|
||||
await cleanThreads(page);
|
||||
await waitForReload(page);
|
||||
});
|
||||
|
||||
test.describe("emoji reactions API", () => {
|
||||
test("adding a reaction persists it", async () => {
|
||||
const { id: threadId, comments } = await seedAndReload(user.page, {
|
||||
body: "Comment for reaction test",
|
||||
});
|
||||
|
||||
const commentId = comments[0].id;
|
||||
|
||||
// Toggle a reaction via API
|
||||
const reactions = await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:toggle-reaction", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
emoji: "\ud83d\udc4d",
|
||||
author: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
|
||||
expect(reactions).toBeTruthy();
|
||||
expect(reactions.length).toBe(1);
|
||||
expect(reactions[0].emoji).toBe("\ud83d\udc4d");
|
||||
expect(reactions[0].authors).toContain("TestUser");
|
||||
});
|
||||
|
||||
test("toggling same reaction removes it", async () => {
|
||||
const { id: threadId, comments } = await seedAndReload(user.page, {
|
||||
body: "Comment for toggle test",
|
||||
});
|
||||
|
||||
const commentId = comments[0].id;
|
||||
|
||||
// Add reaction
|
||||
await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
await docmd.call("threads:toggle-reaction", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
emoji: "\ud83d\udc4d",
|
||||
author: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
|
||||
// Wait for hot-reload after file write
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Toggle it off
|
||||
const reactions = await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:toggle-reaction", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
emoji: "\ud83d\udc4d",
|
||||
author: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
|
||||
// Reaction should be removed
|
||||
const thumbsReaction = reactions.find((r: any) => r.emoji === "\ud83d\udc4d");
|
||||
expect(thumbsReaction).toBeFalsy();
|
||||
});
|
||||
|
||||
test("multiple reactions on same comment", async () => {
|
||||
const { id: threadId, comments } = await seedAndReload(user.page, {
|
||||
body: "Comment for multi-reaction test",
|
||||
});
|
||||
|
||||
const commentId = comments[0].id;
|
||||
|
||||
// Add thumbs up
|
||||
await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
await docmd.call("threads:toggle-reaction", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
emoji: "\ud83d\udc4d",
|
||||
author: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
|
||||
// Wait for hot-reload
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Add party
|
||||
const reactions = await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:toggle-reaction", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
emoji: "\ud83c\udf89",
|
||||
author: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
|
||||
expect(reactions.length).toBe(2);
|
||||
expect(reactions.find((r: any) => r.emoji === "\ud83d\udc4d")).toBeTruthy();
|
||||
expect(reactions.find((r: any) => r.emoji === "\ud83c\udf89")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("reactions persist after re-fetching threads", async () => {
|
||||
const { id: threadId, comments } = await seedAndReload(user.page, {
|
||||
body: "Comment for persistence test",
|
||||
});
|
||||
|
||||
const commentId = comments[0].id;
|
||||
|
||||
// Add reaction
|
||||
await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
await docmd.call("threads:toggle-reaction", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
emoji: "\ud83d\udc4d",
|
||||
author: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
|
||||
// Wait for hot-reload
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Re-fetch threads
|
||||
const threads = await user.page.evaluate(async () => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:get-threads", { file });
|
||||
});
|
||||
|
||||
const thread = threads.find((t: any) => t.id === threadId);
|
||||
expect(thread).toBeTruthy();
|
||||
|
||||
const comment = thread.comments[0];
|
||||
expect(comment.reactions).toBeTruthy();
|
||||
expect(comment.reactions.length).toBe(1);
|
||||
expect(comment.reactions[0].emoji).toBe("\ud83d\udc4d");
|
||||
expect(comment.reactions[0].authors).toContain("TestUser");
|
||||
});
|
||||
|
||||
test("multiple authors on same reaction", async () => {
|
||||
const { id: threadId, comments } = await seedAndReload(user.page, {
|
||||
body: "Comment for multi-author test",
|
||||
});
|
||||
|
||||
const commentId = comments[0].id;
|
||||
|
||||
// TestUser adds thumbs up
|
||||
await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
await docmd.call("threads:toggle-reaction", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
emoji: "\ud83d\udc4d",
|
||||
author: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
|
||||
// Wait for hot-reload
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Alice adds thumbs up
|
||||
const reactions = await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:toggle-reaction", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
emoji: "\ud83d\udc4d",
|
||||
author: "Alice",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
|
||||
const thumbs = reactions.find((r: any) => r.emoji === "\ud83d\udc4d");
|
||||
expect(thumbs).toBeTruthy();
|
||||
expect(thumbs.authors).toContain("TestUser");
|
||||
expect(thumbs.authors).toContain("Alice");
|
||||
expect(thumbs.authors.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("server-rendered reactions", () => {
|
||||
test("reactions block rendered in server HTML when reactions exist", async () => {
|
||||
// Seed a thread with a reaction, then reload to see server rendering
|
||||
const { id: threadId, comments } = await seedAndReload(user.page, {
|
||||
body: "Comment with reaction",
|
||||
});
|
||||
|
||||
const commentId = comments[0].id;
|
||||
|
||||
// Add reaction
|
||||
await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
await docmd.call("threads:toggle-reaction", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
emoji: "\ud83d\udc4d",
|
||||
author: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
|
||||
// Reload to see server-rendered HTML
|
||||
await waitForReload(user.page);
|
||||
|
||||
const thread = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
const reactionsBlock = thread.locator(".threads-reactions");
|
||||
|
||||
// If reactions are rendered server-side, the block should be present
|
||||
if ((await reactionsBlock.count()) > 0) {
|
||||
await expect(reactionsBlock).toContainText("\ud83d\udc4d");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Reply and deletion tests - adapted from old editing-deletion.e2e.spec.ts
|
||||
* and devtalk.e2e.spec.ts "discussions" block.
|
||||
*
|
||||
* Changes from old plugin:
|
||||
* - No sidebar panel; threads are inline server-rendered cards
|
||||
* - Reply button on thread footer (not an always-visible reply textarea)
|
||||
* - Delete confirmation via wa-dialog#delete-dialog (same as before)
|
||||
* - Seeding via docmd.call() instead of HTTP API
|
||||
* - Comment editing now handled by threads-comment Lit component with tc-* classes
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
Actor,
|
||||
ReplyToThread,
|
||||
} from "./screenplay.ts";
|
||||
import { cleanThreads, setAuthor, seedAndReload, waitForReload, waitForClientProcessing } from "./helpers.ts";
|
||||
|
||||
let user: Actor;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
user = new Actor("TestUser", page);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
await setAuthor(page, "TestUser");
|
||||
await cleanThreads(page);
|
||||
await waitForReload(page);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reply to thread
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("reply to thread", () => {
|
||||
test("clicking Reply opens inline editor inside thread card", async () => {
|
||||
// Use an anchored thread so it gets moved inline (unanchored threads
|
||||
// stay in the hidden sidebar and the Reply button is not visible)
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
body: "Thread for reply test",
|
||||
anchor: { quote: "verify core engine" },
|
||||
});
|
||||
|
||||
await waitForClientProcessing(user.page);
|
||||
|
||||
const threadEl = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
await expect(threadEl).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const replyBtn = threadEl.locator(".threads-comment-reply-btn").first();
|
||||
await expect(replyBtn).toBeVisible({ timeout: 5_000 });
|
||||
await replyBtn.click();
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
await expect(editor).toBeAttached({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("submitting reply adds a comment to the thread", async () => {
|
||||
// Use anchored thread so it's visible inline
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
body: "Thread for reply submission",
|
||||
anchor: { quote: "verify core engine" },
|
||||
});
|
||||
|
||||
await waitForClientProcessing(user.page);
|
||||
|
||||
const threadSelector = `.threads-thread[data-thread-id="${threadId}"]`;
|
||||
|
||||
await user.perform(
|
||||
ReplyToThread.on(threadSelector, "I agree with this"),
|
||||
);
|
||||
|
||||
// Wait for reload after the reply is submitted
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Verify reply was persisted via API
|
||||
const threads = await user.page.evaluate(async () => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:get-threads", { file });
|
||||
});
|
||||
|
||||
const thread = threads.find((t: any) => t.id === threadId);
|
||||
expect(thread).toBeTruthy();
|
||||
expect(thread.comments.length).toBe(2);
|
||||
expect(thread.comments[1].body).toBe("I agree with this");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Thread deletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("thread deletion", () => {
|
||||
test("delete dialog opens when delete is requested", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
body: "Thread to delete",
|
||||
});
|
||||
|
||||
const threadEl = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
await expect(threadEl).toBeAttached({ timeout: 5_000 });
|
||||
|
||||
// The threads-app component handles delete via event delegation
|
||||
// We need to trigger the delete event on the thread card
|
||||
// Since server-rendered threads don't have a built-in delete button,
|
||||
// check if there's a mechanism. The threads-app handles delete-dialog.
|
||||
// Let's check: the old plugin had a Delete button in the thread footer.
|
||||
// In the new plugin, there's no delete button on server-rendered cards
|
||||
// (only the Lit-rendered panel threads had Resolve/Delete).
|
||||
// However, the delete functionality exists via the delete-dialog.
|
||||
|
||||
// For now, verify the delete dialog mechanism works:
|
||||
const dialog = user.page.locator("wa-dialog#delete-dialog");
|
||||
await expect(dialog).toBeAttached();
|
||||
});
|
||||
|
||||
test("confirm delete removes thread via API", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
body: "Thread to delete via API",
|
||||
});
|
||||
|
||||
// Delete via WebSocket API
|
||||
await user.page.evaluate(
|
||||
async ({ threadId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
await docmd.call("threads:delete-thread", { file, threadId });
|
||||
},
|
||||
{ threadId },
|
||||
);
|
||||
|
||||
// Wait for hot-reload triggered by file write
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Verify deletion
|
||||
const threads = await user.page.evaluate(async () => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:get-threads", { file });
|
||||
});
|
||||
|
||||
const deleted = threads.find((t: any) => t.id === threadId);
|
||||
expect(deleted).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment deletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("comment deletion", () => {
|
||||
test("deleting a reply leaves the root comment intact", async () => {
|
||||
const { id: threadId, comments } = await seedAndReload(user.page, {
|
||||
body: "Root comment",
|
||||
replies: [{ body: "Reply to remove", author: "TestUser" }],
|
||||
});
|
||||
|
||||
const replyId = comments[1].id;
|
||||
|
||||
// Delete via API
|
||||
await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
await docmd.call("threads:delete-comment", { file, threadId, commentId });
|
||||
},
|
||||
{ threadId, commentId: replyId },
|
||||
);
|
||||
|
||||
// Wait for hot-reload triggered by file write
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Verify only root comment remains
|
||||
const threads = await user.page.evaluate(async () => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:get-threads", { file });
|
||||
});
|
||||
|
||||
const thread = threads.find((t: any) => t.id === threadId);
|
||||
expect(thread).toBeTruthy();
|
||||
expect(thread.comments.length).toBe(1);
|
||||
expect(thread.comments[0].body).toBe("Root comment");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Comment editing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test.describe("comment editing", () => {
|
||||
test("editing a comment persists the change", async () => {
|
||||
const { id: threadId, comments } = await seedAndReload(user.page, {
|
||||
body: "Original body for edit test",
|
||||
});
|
||||
|
||||
const commentId = comments[0].id;
|
||||
const newBody = "Edited comment body";
|
||||
|
||||
// Edit via API
|
||||
await user.page.evaluate(
|
||||
async ({ threadId, commentId, newBody }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
await docmd.call("threads:edit-comment", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
body: newBody,
|
||||
});
|
||||
},
|
||||
{ threadId, commentId, newBody },
|
||||
);
|
||||
|
||||
// Wait for hot-reload triggered by file write
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Verify edit persisted
|
||||
const threads = await user.page.evaluate(async () => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:get-threads", { file });
|
||||
});
|
||||
|
||||
const thread = threads.find((t: any) => t.id === threadId);
|
||||
expect(thread.comments[0].body).toBe(newBody);
|
||||
expect(thread.comments[0].edited_at).toBeTruthy();
|
||||
});
|
||||
|
||||
test("editing with empty body does nothing (guard)", async () => {
|
||||
const { id: threadId, comments } = await seedAndReload(user.page, {
|
||||
body: "Original content",
|
||||
});
|
||||
|
||||
const commentId = comments[0].id;
|
||||
|
||||
// Attempt to edit with empty body - the API should reject it or the guard should prevent it
|
||||
try {
|
||||
await user.page.evaluate(
|
||||
async ({ threadId, commentId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
await docmd.call("threads:edit-comment", {
|
||||
file,
|
||||
threadId,
|
||||
commentId,
|
||||
body: "",
|
||||
});
|
||||
},
|
||||
{ threadId, commentId },
|
||||
);
|
||||
} catch {
|
||||
// Expected - empty body should be rejected
|
||||
}
|
||||
|
||||
// Verify original body unchanged
|
||||
const threads = await user.page.evaluate(async () => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:get-threads", { file });
|
||||
});
|
||||
|
||||
const thread = threads.find((t: any) => t.id === threadId);
|
||||
expect(thread.comments[0].body).toBe("Original content");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Thread resolve/unresolve tests - adapted from old devtalk.e2e.spec.ts
|
||||
* "thread behavior" block.
|
||||
*
|
||||
* Changes from old plugin:
|
||||
* - No sidebar panel with visual resolve/unresolve buttons on server-rendered cards
|
||||
* - Resolve functionality exists at API level (threads:resolve-thread)
|
||||
* - Resolved threads get .threads-thread--resolved class (opacity: 0.55)
|
||||
* - The Lit-rendered threads-thread component has Resolve/Unresolve buttons,
|
||||
* but the current inline mode uses server-rendered HTML
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Actor } from "./screenplay.ts";
|
||||
import { cleanThreads, setAuthor, seedAndReload, waitForReload } from "./helpers.ts";
|
||||
|
||||
let user: Actor;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
user = new Actor("TestUser", page);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
await setAuthor(page, "TestUser");
|
||||
await cleanThreads(page);
|
||||
await waitForReload(page);
|
||||
});
|
||||
|
||||
test.describe("resolve thread API", () => {
|
||||
test("resolving a thread sets resolved state", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
body: "Thread to resolve",
|
||||
});
|
||||
|
||||
// Resolve via API
|
||||
const resolved = await user.page.evaluate(
|
||||
async ({ threadId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:resolve-thread", {
|
||||
file,
|
||||
threadId,
|
||||
resolved_by: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId },
|
||||
);
|
||||
|
||||
expect(resolved.resolved).toBeTruthy();
|
||||
|
||||
// The resolve call wrote to the file, triggering hot-reload. Wait for it.
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Verify via re-fetch
|
||||
const threads = await user.page.evaluate(async () => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:get-threads", { file });
|
||||
});
|
||||
|
||||
const thread = threads.find((t: any) => t.id === threadId);
|
||||
expect(thread.resolved).toBeTruthy();
|
||||
});
|
||||
|
||||
test("unresolving a thread toggles back to open", async () => {
|
||||
// Seed a resolved thread
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
body: "Resolved thread for unresolve test",
|
||||
resolved: true,
|
||||
});
|
||||
|
||||
// Unresolve by calling resolve-thread again (it toggles)
|
||||
const unresolved = await user.page.evaluate(
|
||||
async ({ threadId }) => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:resolve-thread", {
|
||||
file,
|
||||
threadId,
|
||||
resolved_by: "TestUser",
|
||||
});
|
||||
},
|
||||
{ threadId },
|
||||
);
|
||||
|
||||
expect(unresolved.resolved).toBeFalsy();
|
||||
});
|
||||
|
||||
test("resolved thread gets --resolved CSS class on server render", async () => {
|
||||
const { id: threadId } = await seedAndReload(user.page, {
|
||||
body: "Thread for CSS test",
|
||||
resolved: true,
|
||||
});
|
||||
|
||||
// The resolved state requires an extra reload since seedThread does
|
||||
// add-thread + resolve as separate file writes
|
||||
await waitForReload(user.page);
|
||||
|
||||
const threadEl = user.page.locator(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
await expect(threadEl).toBeAttached({ timeout: 5_000 });
|
||||
|
||||
// Server-rendered resolved threads should have the resolved class
|
||||
const hasResolvedClass = await threadEl.evaluate((el) =>
|
||||
el.classList.contains("threads-thread--resolved"),
|
||||
);
|
||||
expect(hasResolvedClass).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Playwright Screenplay primitives for e2e testing the threads plugin.
|
||||
*
|
||||
* Adapted from old-discussions-plugin/src/e2e/screenplay.ts for the new
|
||||
* inline-only architecture (no sidebar panel, server-rendered threads,
|
||||
* WebSocket actions instead of HTTP API).
|
||||
*/
|
||||
|
||||
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ability: BrowseTheWeb
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BrowseTheWeb {
|
||||
constructor(public readonly page: Page) {}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Actor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class Actor {
|
||||
private browser: BrowseTheWeb;
|
||||
|
||||
constructor(
|
||||
public readonly name: string,
|
||||
page: Page,
|
||||
) {
|
||||
this.browser = new BrowseTheWeb(page);
|
||||
}
|
||||
|
||||
get page(): Page {
|
||||
return this.browser.page;
|
||||
}
|
||||
|
||||
async perform<T>(task: Task<T>): Promise<T> {
|
||||
return task.performAs(this);
|
||||
}
|
||||
|
||||
async ask<T>(question: Question<T>): Promise<T> {
|
||||
return question.answeredBy(this);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Task<T> {
|
||||
performAs(actor: Actor): Promise<T>;
|
||||
}
|
||||
|
||||
export interface Question<T> {
|
||||
answeredBy(actor: Actor): Promise<T>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tasks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class NavigateTo implements Task<void> {
|
||||
constructor(private path: string) {}
|
||||
|
||||
async performAs(actor: Actor): Promise<void> {
|
||||
await actor.page.goto(this.path);
|
||||
// Wait for threads-app to be present (plugin loaded)
|
||||
await actor.page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
static path(path: string) {
|
||||
return new NavigateTo(path);
|
||||
}
|
||||
}
|
||||
|
||||
export class SelectText implements Task<void> {
|
||||
constructor(
|
||||
private selector: string,
|
||||
private text: string,
|
||||
) {}
|
||||
|
||||
async performAs(actor: Actor): Promise<void> {
|
||||
const sel = this.selector;
|
||||
const txt = this.text;
|
||||
const coords = await actor.page.evaluate(
|
||||
({ selector, text }) => {
|
||||
const elements = document.querySelectorAll(selector);
|
||||
for (const el of elements) {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, null);
|
||||
let node: Text | null;
|
||||
while ((node = walker.nextNode() as Text | null)) {
|
||||
const idx = node.textContent?.indexOf(text) ?? -1;
|
||||
if (idx >= 0) {
|
||||
const range = document.createRange();
|
||||
range.setStart(node, idx);
|
||||
range.setEnd(node, idx + text.length);
|
||||
const rects = range.getClientRects();
|
||||
const startRect = rects[0];
|
||||
const endRect = rects[rects.length - 1];
|
||||
if (!startRect || !endRect) {
|
||||
const rect = range.getBoundingClientRect();
|
||||
return {
|
||||
startX: rect.left,
|
||||
startY: rect.top + rect.height / 2,
|
||||
endX: rect.right,
|
||||
endY: rect.top + rect.height / 2,
|
||||
};
|
||||
}
|
||||
return {
|
||||
startX: startRect.left,
|
||||
startY: startRect.top + startRect.height / 2,
|
||||
endX: endRect.right,
|
||||
endY: endRect.top + endRect.height / 2,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
{ selector: sel, text: txt },
|
||||
);
|
||||
|
||||
if (!coords) throw new Error(`Text "${txt}" not found in ${sel}`);
|
||||
|
||||
await actor.page.mouse.move(coords.startX, coords.startY);
|
||||
await actor.page.mouse.down();
|
||||
await actor.page.mouse.move(coords.endX, coords.endY);
|
||||
await actor.page.mouse.up();
|
||||
}
|
||||
|
||||
static within(selector: string, text: string) {
|
||||
return new SelectText(selector, text);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Select text and use the popover "Add comment" to open an inline editor,
|
||||
* then fill and submit to create a new thread.
|
||||
*/
|
||||
export class StartThreadOn implements Task<void> {
|
||||
constructor(
|
||||
private selector: string,
|
||||
private selectedText: string,
|
||||
private comment: string,
|
||||
) {}
|
||||
|
||||
async performAs(actor: Actor): Promise<void> {
|
||||
await actor.perform(SelectText.within(this.selector, this.selectedText));
|
||||
|
||||
// Wait for popover and click "Add comment"
|
||||
const popoverBtn = actor.page.locator("threads-popover").getByText("Add comment");
|
||||
await popoverBtn.waitFor({ state: "visible", timeout: 5_000 });
|
||||
await popoverBtn.click();
|
||||
|
||||
// Fill in the inline editor
|
||||
const editor = actor.page.locator("threads-inline-editor");
|
||||
await editor.waitFor({ state: "attached", timeout: 5_000 });
|
||||
|
||||
const textarea = editor.locator("wa-textarea").locator("textarea");
|
||||
await textarea.waitFor({ timeout: 5_000 });
|
||||
await textarea.fill(this.comment);
|
||||
|
||||
// Submit
|
||||
const submitBtn = editor.locator("wa-button[variant='brand']");
|
||||
await submitBtn.click();
|
||||
|
||||
// Wait for editor to detach (submission complete)
|
||||
await editor.waitFor({ state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
static on(selector: string, selectedText: string, comment: string) {
|
||||
return new StartThreadOn(selector, selectedText, comment);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the "New Thread" button and submit a general (unanchored) thread.
|
||||
*/
|
||||
export class AddNewThread implements Task<void> {
|
||||
constructor(private body: string) {}
|
||||
|
||||
async performAs(actor: Actor): Promise<void> {
|
||||
const newThreadBtn = actor.page.locator(".threads-new-thread-btn").first();
|
||||
await newThreadBtn.click();
|
||||
|
||||
const editor = actor.page.locator("threads-inline-editor");
|
||||
await editor.waitFor({ state: "attached", timeout: 5_000 });
|
||||
|
||||
const textarea = editor.locator("wa-textarea").locator("textarea");
|
||||
await textarea.waitFor({ timeout: 5_000 });
|
||||
await textarea.fill(this.body);
|
||||
|
||||
const submitBtn = editor.locator("wa-button[variant='brand']");
|
||||
await submitBtn.click();
|
||||
|
||||
await editor.waitFor({ state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
static withBody(body: string) {
|
||||
return new AddNewThread(body);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click the Reply button on a server-rendered thread card and submit a reply.
|
||||
*/
|
||||
export class ReplyToThread implements Task<void> {
|
||||
constructor(
|
||||
private threadSelector: string,
|
||||
private comment: string,
|
||||
) {}
|
||||
|
||||
async performAs(actor: Actor): Promise<void> {
|
||||
const threadEl = actor.page.locator(this.threadSelector);
|
||||
const replyBtn = threadEl.locator(".threads-comment-reply-btn").first();
|
||||
await replyBtn.click();
|
||||
|
||||
const editor = actor.page.locator("threads-inline-editor");
|
||||
await editor.waitFor({ state: "attached", timeout: 5_000 });
|
||||
|
||||
const textarea = editor.locator("wa-textarea").locator("textarea");
|
||||
await textarea.waitFor({ timeout: 5_000 });
|
||||
await textarea.fill(this.comment);
|
||||
|
||||
const submitBtn = editor.locator("wa-button[variant='brand']");
|
||||
await submitBtn.click();
|
||||
|
||||
await editor.waitFor({ state: "detached", timeout: 10_000 });
|
||||
}
|
||||
|
||||
static on(threadSelector: string, comment: string) {
|
||||
return new ReplyToThread(threadSelector, comment);
|
||||
}
|
||||
}
|
||||
|
||||
export class SubmitWithKeyboard implements Task<void> {
|
||||
async performAs(actor: Actor): Promise<void> {
|
||||
const editor = actor.page.locator("threads-inline-editor");
|
||||
await editor.waitFor({ state: "attached", timeout: 3_000 });
|
||||
|
||||
const textarea = editor.locator("wa-textarea").locator("textarea");
|
||||
await textarea.click();
|
||||
|
||||
const modifier = process.platform === "darwin" ? "Meta" : "Control";
|
||||
await actor.page.keyboard.press(`${modifier}+Enter`);
|
||||
}
|
||||
|
||||
static inInlineEditor() {
|
||||
return new SubmitWithKeyboard();
|
||||
}
|
||||
}
|
||||
|
||||
export class CancelWithKeyboard implements Task<void> {
|
||||
async performAs(actor: Actor): Promise<void> {
|
||||
const editor = actor.page.locator("threads-inline-editor");
|
||||
await editor.waitFor({ state: "attached", timeout: 3_000 });
|
||||
|
||||
const textarea = editor.locator("wa-textarea").locator("textarea");
|
||||
await textarea.click();
|
||||
|
||||
await actor.page.keyboard.press("Escape");
|
||||
}
|
||||
|
||||
static inInlineEditor() {
|
||||
return new CancelWithKeyboard();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Questions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class TheThreadCount implements Question<number> {
|
||||
async answeredBy(actor: Actor): Promise<number> {
|
||||
return actor.page.locator(".threads-thread[data-thread-id]").count();
|
||||
}
|
||||
|
||||
static count() {
|
||||
return new TheThreadCount();
|
||||
}
|
||||
}
|
||||
|
||||
export class TheHighlightCount implements Question<number> {
|
||||
async answeredBy(actor: Actor): Promise<number> {
|
||||
return actor.page.locator("mark.threads-highlight[data-thread-id]").count();
|
||||
}
|
||||
|
||||
static count() {
|
||||
return new TheHighlightCount();
|
||||
}
|
||||
}
|
||||
|
||||
export class TheInlineEditor implements Question<boolean> {
|
||||
async answeredBy(actor: Actor): Promise<boolean> {
|
||||
return (await actor.page.locator("threads-inline-editor").count()) > 0;
|
||||
}
|
||||
|
||||
static isVisible() {
|
||||
return new TheInlineEditor();
|
||||
}
|
||||
}
|
||||
|
||||
export class TheDeleteDialog implements Question<boolean> {
|
||||
async answeredBy(actor: Actor): Promise<boolean> {
|
||||
return actor.page.locator("wa-dialog#delete-dialog").isVisible();
|
||||
}
|
||||
|
||||
static isOpen() {
|
||||
return new TheDeleteDialog();
|
||||
}
|
||||
}
|
||||
|
||||
export class TheCommentBody implements Question<string> {
|
||||
constructor(
|
||||
private threadId: string,
|
||||
private commentIndex: number,
|
||||
) {}
|
||||
|
||||
async answeredBy(actor: Actor): Promise<string> {
|
||||
const thread = actor.page.locator(`.threads-thread[data-thread-id="${this.threadId}"]`);
|
||||
const comment = thread.locator(".threads-comment").nth(this.commentIndex);
|
||||
const body = comment.locator(".threads-comment__body");
|
||||
return (await body.textContent()) ?? "";
|
||||
}
|
||||
|
||||
static of(threadId: string, commentIndex: number) {
|
||||
return new TheCommentBody(threadId, commentIndex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* Thread creation tests - adapted from old devtalk.e2e.spec.ts "discussions" block
|
||||
* and filters-general.e2e.spec.ts "general comments" block.
|
||||
*
|
||||
* Changes from old plugin:
|
||||
* - No sidebar panel; threads are inline in the content area
|
||||
* - "Add comment" popover opens inline editor (not a panel compose form)
|
||||
* - "New Thread" button replaces "Add general comment" panel button
|
||||
* - WebSocket actions instead of HTTP API for verification
|
||||
*/
|
||||
|
||||
import { test, expect } from "@playwright/test";
|
||||
import {
|
||||
Actor,
|
||||
NavigateTo,
|
||||
StartThreadOn,
|
||||
AddNewThread,
|
||||
SelectText,
|
||||
TheHighlightCount,
|
||||
} from "./screenplay.ts";
|
||||
import { cleanThreads, setAuthor, waitForReload } from "./helpers.ts";
|
||||
|
||||
const contentSelector = "[data-docmd-content] p, .docmd-content p, article p, main p";
|
||||
|
||||
let user: Actor;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
user = new Actor("TestUser", page);
|
||||
await page.goto("/");
|
||||
await page.waitForSelector("threads-app", { state: "attached", timeout: 10_000 });
|
||||
await setAuthor(page, "TestUser");
|
||||
await cleanThreads(page);
|
||||
// Reload to reflect the clean state
|
||||
await waitForReload(page);
|
||||
});
|
||||
|
||||
test.describe("creating threads via text selection", () => {
|
||||
test("start a thread by selecting text", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
|
||||
|
||||
const firstParagraph = user.page.locator(contentSelector).first();
|
||||
const paragraphText = await firstParagraph.textContent();
|
||||
|
||||
if (!paragraphText || paragraphText.trim().length < 5) {
|
||||
test.skip(true, "No suitable text content found to select");
|
||||
return;
|
||||
}
|
||||
|
||||
const textToSelect = paragraphText.trim().slice(0, 20);
|
||||
|
||||
await user.perform(
|
||||
StartThreadOn.on(contentSelector, textToSelect, "This needs clarification"),
|
||||
);
|
||||
|
||||
// Wait for page to reload with the new thread
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Thread should appear in the content area
|
||||
const threads = user.page.locator(".threads-thread[data-thread-id]");
|
||||
// Count may include pre-existing threads from markdown, so just check we have at least 1
|
||||
const count = await threads.count();
|
||||
expect(count).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("highlights appear after creating a thread", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
|
||||
|
||||
const firstParagraph = user.page.locator(contentSelector).first();
|
||||
const paragraphText = await firstParagraph.textContent();
|
||||
|
||||
if (!paragraphText || paragraphText.trim().length < 5) {
|
||||
test.skip(true, "No suitable text content found");
|
||||
return;
|
||||
}
|
||||
|
||||
const highlightsBefore = await user.ask(TheHighlightCount.count());
|
||||
|
||||
const textToSelect = paragraphText.trim().slice(0, 15);
|
||||
await user.perform(
|
||||
StartThreadOn.on(contentSelector, textToSelect, "Highlight test"),
|
||||
);
|
||||
|
||||
await waitForReload(user.page);
|
||||
|
||||
// At least one new highlight should have appeared
|
||||
const highlightsAfter = await user.ask(TheHighlightCount.count());
|
||||
expect(highlightsAfter).toBeGreaterThan(highlightsBefore);
|
||||
});
|
||||
|
||||
test("popover shows 'Add comment' after text selection", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
|
||||
|
||||
const firstParagraph = user.page.locator(contentSelector).first();
|
||||
const paragraphText = await firstParagraph.textContent();
|
||||
|
||||
if (!paragraphText || paragraphText.trim().length < 5) {
|
||||
test.skip(true, "No suitable text content found");
|
||||
return;
|
||||
}
|
||||
|
||||
const textToSelect = paragraphText.trim().slice(0, 15);
|
||||
await user.perform(SelectText.within(contentSelector, textToSelect));
|
||||
|
||||
const popoverBtn = user.page.locator("threads-popover").getByText("Add comment");
|
||||
await expect(popoverBtn).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("creating general (unanchored) threads", () => {
|
||||
test("clicking New Thread opens inline editor", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
const newThreadBtn = user.page.locator(".threads-new-thread-btn").first();
|
||||
await newThreadBtn.click();
|
||||
|
||||
const editor = user.page.locator("threads-inline-editor");
|
||||
await expect(editor).toBeAttached({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("submitting New Thread creates unanchored thread", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
await user.perform(AddNewThread.withBody("A general observation"));
|
||||
|
||||
// After submission, the page should reload and show the new thread
|
||||
await waitForReload(user.page);
|
||||
|
||||
// Verify thread was persisted via WebSocket API
|
||||
const threads = await user.page.evaluate(async () => {
|
||||
const file = document.body.dataset["sourceFile"];
|
||||
return docmd.call("threads:get-threads", { file });
|
||||
});
|
||||
|
||||
const generalThread = threads.find(
|
||||
(t: any) => t.comments[0]?.body === "A general observation",
|
||||
);
|
||||
expect(generalThread).toBeTruthy();
|
||||
|
||||
// Unanchored threads have no ==highlight== markup on the page
|
||||
const highlightForThread = await user.page.locator(
|
||||
`mark.threads-highlight[data-thread-id="${generalThread.id}"]`,
|
||||
).count();
|
||||
expect(highlightForThread).toBe(0);
|
||||
});
|
||||
|
||||
test("general thread has no highlight on page", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
|
||||
const highlightsBefore = await user.ask(TheHighlightCount.count());
|
||||
|
||||
await user.perform(AddNewThread.withBody("No highlight expected"));
|
||||
await waitForReload(user.page);
|
||||
|
||||
// No new highlights should have appeared
|
||||
const highlightsAfter = await user.ask(TheHighlightCount.count());
|
||||
expect(highlightsAfter).toBe(highlightsBefore);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("popover behavior", () => {
|
||||
test("mousedown outside popover dismisses it", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
|
||||
|
||||
const firstParagraph = user.page.locator(contentSelector).first();
|
||||
const paragraphText = await firstParagraph.textContent();
|
||||
if (!paragraphText || paragraphText.trim().length < 5) {
|
||||
test.skip(true, "No suitable text content");
|
||||
return;
|
||||
}
|
||||
|
||||
const textToSelect = paragraphText.trim().slice(0, 15);
|
||||
await user.perform(SelectText.within(contentSelector, textToSelect));
|
||||
|
||||
await expect(
|
||||
user.page.locator("threads-popover").getByText("Add comment"),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click outside
|
||||
await user.page.locator("h1, body").first().click();
|
||||
|
||||
await expect(
|
||||
user.page.locator("threads-popover").getByText("Add comment"),
|
||||
).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("collapsing text selection hides popover", async () => {
|
||||
await user.perform(NavigateTo.path("/"));
|
||||
await user.page.waitForSelector(contentSelector, { timeout: 5_000 });
|
||||
|
||||
const firstParagraph = user.page.locator(contentSelector).first();
|
||||
const paragraphText = await firstParagraph.textContent();
|
||||
if (!paragraphText || paragraphText.trim().length < 5) {
|
||||
test.skip(true, "No suitable text content");
|
||||
return;
|
||||
}
|
||||
|
||||
const textToSelect = paragraphText.trim().slice(0, 15);
|
||||
await user.perform(SelectText.within(contentSelector, textToSelect));
|
||||
|
||||
await expect(
|
||||
user.page.locator("threads-popover").getByText("Add comment"),
|
||||
).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Click to collapse selection
|
||||
await user.page.mouse.click(10, 10);
|
||||
|
||||
await expect(
|
||||
user.page.locator("threads-popover").getByText("Add comment"),
|
||||
).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"types": ["node", "@playwright/test"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"threads": "Threads",
|
||||
"newThread": "New Thread",
|
||||
"newComment": "New Comment",
|
||||
"startThread": "Start a new discussion thread",
|
||||
"addGeneralComment": "Add general comment",
|
||||
"collapseSidebar": "Collapse sidebar",
|
||||
"openThreadsPanel": "Open threads panel",
|
||||
"closePanel": "Close panel",
|
||||
"jumpToThreads": "Jump to threads",
|
||||
"all": "All",
|
||||
"open": "Open",
|
||||
"resolved": "Resolved",
|
||||
"noThreads": "No threads on this page yet.",
|
||||
"selectTextToStart": "Select text to start one.",
|
||||
"writeComment": "Write your comment...",
|
||||
"addComment": "Add a comment...",
|
||||
"cmdEnterSubmit": "Cmd+Enter to submit",
|
||||
"cancel": "Cancel",
|
||||
"submit": "Submit",
|
||||
"saving": "Saving...",
|
||||
"comment": "Comment",
|
||||
"reply": "Reply",
|
||||
"replyToComment": "Reply to this comment",
|
||||
"deleteComment": "Delete comment",
|
||||
"collapseThread": "Collapse thread",
|
||||
"expandThread": "Expand thread",
|
||||
"confirmDelete": "Confirm Delete",
|
||||
"confirmDeleteMessage": "Are you sure you want to delete this",
|
||||
"delete": "Delete",
|
||||
"discussionIdentity": "Discussion identity",
|
||||
"displayName": "Display Name",
|
||||
"yourName": "Your name",
|
||||
"githubUsername": "GitHub Username",
|
||||
"gravatarEmail": "Gravatar Email",
|
||||
"save": "Save",
|
||||
"avatarHint": "Avatar: Gravatar > GitHub > random. Stored in your browser only.",
|
||||
"openCount": "open",
|
||||
"commentCount": "comment",
|
||||
"commentsCount": "comments",
|
||||
"by": "by",
|
||||
"and": "and",
|
||||
"more": "more"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"threads": "चर्चाएँ",
|
||||
"newThread": "नई चर्चा",
|
||||
"newComment": "नई टिप्पणी",
|
||||
"startThread": "नई चर्चा शुरू करें",
|
||||
"addGeneralComment": "सामान्य टिप्पणी जोड़ें",
|
||||
"collapseSidebar": "साइडबार छोटा करें",
|
||||
"openThreadsPanel": "चर्चा पैनल खोलें",
|
||||
"closePanel": "पैनल बंद करें",
|
||||
"jumpToThreads": "चर्चाओं पर जाएँ",
|
||||
"all": "सभी",
|
||||
"open": "खुली",
|
||||
"resolved": "हल की गई",
|
||||
"noThreads": "इस पृष्ठ पर अभी कोई चर्चा नहीं है।",
|
||||
"selectTextToStart": "शुरू करने के लिए टेक्स्ट चुनें।",
|
||||
"writeComment": "अपनी टिप्पणी लिखें...",
|
||||
"addComment": "टिप्पणी जोड़ें...",
|
||||
"cmdEnterSubmit": "Cmd+Enter भेजने के लिए",
|
||||
"cancel": "रद्द करें",
|
||||
"submit": "भेजें",
|
||||
"saving": "सहेजा जा रहा है...",
|
||||
"comment": "टिप्पणी",
|
||||
"reply": "जवाब",
|
||||
"replyToComment": "इस टिप्पणी का जवाब दें",
|
||||
"deleteComment": "टिप्पणी हटाएँ",
|
||||
"collapseThread": "चर्चा छोटी करें",
|
||||
"expandThread": "चर्चा बड़ी करें",
|
||||
"confirmDelete": "हटाने की पुष्टि करें",
|
||||
"confirmDeleteMessage": "क्या आप वाकई इसे हटाना चाहते हैं",
|
||||
"delete": "हटाएँ",
|
||||
"discussionIdentity": "चर्चा पहचान",
|
||||
"displayName": "प्रदर्शन नाम",
|
||||
"yourName": "आपका नाम",
|
||||
"githubUsername": "GitHub उपयोगकर्ता नाम",
|
||||
"gravatarEmail": "Gravatar ईमेल",
|
||||
"save": "सहेजें",
|
||||
"avatarHint": "अवतार: Gravatar > GitHub > यादृच्छिक। केवल आपके ब्राउज़र में संग्रहीत।",
|
||||
"openCount": "खुली",
|
||||
"commentCount": "टिप्पणी",
|
||||
"commentsCount": "टिप्पणियाँ",
|
||||
"by": "द्वारा",
|
||||
"and": "और",
|
||||
"more": "अधिक"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"threads": "스레드",
|
||||
"newThread": "새 스레드",
|
||||
"newComment": "새 댓글",
|
||||
"startThread": "새 토론 스레드 시작",
|
||||
"addGeneralComment": "일반 댓글 추가",
|
||||
"collapseSidebar": "사이드바 접기",
|
||||
"openThreadsPanel": "스레드 패널 열기",
|
||||
"closePanel": "패널 닫기",
|
||||
"jumpToThreads": "스레드로 이동",
|
||||
"all": "전체",
|
||||
"open": "미해결",
|
||||
"resolved": "해결됨",
|
||||
"noThreads": "아직 이 페이지에 스레드가 없습니다.",
|
||||
"selectTextToStart": "시작하려면 텍스트를 선택하세요.",
|
||||
"writeComment": "댓글을 입력하세요...",
|
||||
"addComment": "댓글 추가...",
|
||||
"cmdEnterSubmit": "Cmd+Enter로 제출",
|
||||
"cancel": "취소",
|
||||
"submit": "제출",
|
||||
"saving": "저장 중...",
|
||||
"comment": "댓글",
|
||||
"reply": "답글",
|
||||
"replyToComment": "이 댓글에 답글 달기",
|
||||
"deleteComment": "댓글 삭제",
|
||||
"collapseThread": "스레드 접기",
|
||||
"expandThread": "스레드 펼치기",
|
||||
"confirmDelete": "삭제 확인",
|
||||
"confirmDeleteMessage": "정말 삭제하시겠습니까",
|
||||
"delete": "삭제",
|
||||
"discussionIdentity": "토론 신원",
|
||||
"displayName": "표시 이름",
|
||||
"yourName": "이름",
|
||||
"githubUsername": "GitHub 사용자 이름",
|
||||
"gravatarEmail": "Gravatar 이메일",
|
||||
"save": "저장",
|
||||
"avatarHint": "아바타: Gravatar > GitHub > 무작위 순으로 표시됩니다. 브라우저에만 저장됩니다.",
|
||||
"openCount": "미해결",
|
||||
"commentCount": "댓글",
|
||||
"commentsCount": "댓글",
|
||||
"by": "작성:",
|
||||
"and": "및",
|
||||
"more": "명 더"
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"threads": "讨论",
|
||||
"newThread": "新讨论",
|
||||
"newComment": "新评论",
|
||||
"startThread": "开始新讨论",
|
||||
"addGeneralComment": "添加一般评论",
|
||||
"collapseSidebar": "折叠侧边栏",
|
||||
"openThreadsPanel": "打开讨论面板",
|
||||
"closePanel": "关闭面板",
|
||||
"jumpToThreads": "跳转到讨论",
|
||||
"all": "全部",
|
||||
"open": "未解决",
|
||||
"resolved": "已解决",
|
||||
"noThreads": "此页面还没有讨论。",
|
||||
"selectTextToStart": "选择文本开始讨论。",
|
||||
"writeComment": "写下您的评论...",
|
||||
"addComment": "添加评论...",
|
||||
"cmdEnterSubmit": "Cmd+Enter 提交",
|
||||
"cancel": "取消",
|
||||
"submit": "提交",
|
||||
"saving": "保存中...",
|
||||
"comment": "评论",
|
||||
"reply": "回复",
|
||||
"replyToComment": "回复此评论",
|
||||
"deleteComment": "删除评论",
|
||||
"collapseThread": "折叠讨论",
|
||||
"expandThread": "展开讨论",
|
||||
"confirmDelete": "确认删除",
|
||||
"confirmDeleteMessage": "确定要删除此",
|
||||
"delete": "删除",
|
||||
"discussionIdentity": "讨论身份",
|
||||
"displayName": "显示名称",
|
||||
"yourName": "您的名字",
|
||||
"githubUsername": "GitHub 用户名",
|
||||
"gravatarEmail": "Gravatar 邮箱",
|
||||
"save": "保存",
|
||||
"avatarHint": "头像:Gravatar > GitHub > 随机。仅存储在您的浏览器中。",
|
||||
"openCount": "未解决",
|
||||
"commentCount": "条评论",
|
||||
"commentsCount": "条评论",
|
||||
"by": "来自",
|
||||
"and": "和",
|
||||
"more": "更多"
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"name": "@docmd/plugin-threads",
|
||||
"version": "0.8.12",
|
||||
"type": "module",
|
||||
"description": "Inline discussion threads for docmd.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"i18n"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"docmd": {
|
||||
"key": "threads",
|
||||
"kind": "plugin",
|
||||
"displayName": "Threads",
|
||||
"tagline": "Inline discussion comments stored in Markdown.",
|
||||
"capabilities": [
|
||||
"markdown",
|
||||
"body",
|
||||
"assets",
|
||||
"actions",
|
||||
"translations"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && node build.js",
|
||||
"build:client": "node build.js",
|
||||
"test": "echo 'TODO: migrate tests to vitest'"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@docmd/api": "workspace:*",
|
||||
"@docmd/parser": "workspace:*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@awesome.me/webawesome": "^3.10.0",
|
||||
"@docmd/utils": "workspace:*",
|
||||
"lit": "^3.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docmd/api": "workspace:*",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^24.13.3",
|
||||
"esbuild": "^0.28.1"
|
||||
},
|
||||
"keywords": [
|
||||
"docmd",
|
||||
"plugin",
|
||||
"threads",
|
||||
"discussions",
|
||||
"comments",
|
||||
"editor",
|
||||
"collaboration",
|
||||
"markdown",
|
||||
"documentation",
|
||||
"minimalist",
|
||||
"zero-config",
|
||||
"site-generator",
|
||||
"static-site-generator",
|
||||
"docs"
|
||||
],
|
||||
"author": {
|
||||
"name": "Saulo Vallory"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/docmd-io/docmd.git"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "e2e",
|
||||
timeout: 30_000,
|
||||
retries: 0,
|
||||
workers: 1,
|
||||
use: {
|
||||
baseURL: "http://localhost:4175",
|
||||
headless: true,
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { browserName: "chromium" },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: "pnpm --filter @docmd/playground run dev --port 4175",
|
||||
port: 4175,
|
||||
reuseExistingServer: !process.env["CI"],
|
||||
timeout: 30_000,
|
||||
cwd: "../../../", // monorepo root
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,954 @@
|
||||
export function injectComponentStyles(): void {
|
||||
if (document.getElementById("tc-styles")) return;
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.id = "tc-styles";
|
||||
style.textContent = `
|
||||
/* ========= Design tokens ========= */
|
||||
:root {
|
||||
--tc-bg: var(--bg-color, hsl(0 0% 100%));
|
||||
--tc-fg: var(--text-color, hsl(0 0% 9%));
|
||||
--tc-muted: var(--sidebar-bg, hsl(0 0% 96.1%));
|
||||
--tc-muted-fg: var(--text-muted, hsl(0 0% 45.1%));
|
||||
--tc-border: var(--border-color, hsl(0 0% 89.8%));
|
||||
--tc-input: var(--border-color, hsl(0 0% 89.8%));
|
||||
--tc-ring: var(--text-color, hsl(0 0% 9%));
|
||||
--tc-accent: var(--sidebar-bg, hsl(0 0% 96.1%));
|
||||
--tc-accent-fg: var(--text-color, hsl(0 0% 9%));
|
||||
--tc-card: var(--bg-color, hsl(0 0% 100%));
|
||||
--tc-card-fg: var(--text-color, hsl(0 0% 9%));
|
||||
--tc-radius: 6px;
|
||||
--tc-font: var(--font-family-sans, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
|
||||
}
|
||||
|
||||
/* ========= Highlight colors (cycling palette) ========= */
|
||||
.threads-highlight {
|
||||
border-radius: 2px;
|
||||
padding: 1px 0;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.threads-highlight:hover { opacity: 0.75; }
|
||||
|
||||
.threads-hl-yellow { background: hsl(48 96% 89% / 0.6); border-bottom: 2px solid hsl(48 96% 53%); }
|
||||
.threads-hl-blue { background: hsl(210 100% 88% / 0.55); border-bottom: 2px solid hsl(210 100% 55%); }
|
||||
.threads-hl-green { background: hsl(142 60% 82% / 0.55); border-bottom: 2px solid hsl(142 60% 45%); }
|
||||
.threads-hl-pink { background: hsl(340 80% 88% / 0.55); border-bottom: 2px solid hsl(340 80% 55%); }
|
||||
.threads-hl-purple { background: hsl(270 70% 88% / 0.55); border-bottom: 2px solid hsl(270 70% 55%); }
|
||||
.threads-hl-orange { background: hsl(28 100% 86% / 0.55); border-bottom: 2px solid hsl(28 100% 55%); }
|
||||
|
||||
/* Matching left-border colors for thread cards */
|
||||
.threads-border-yellow { border-left-color: hsl(48 96% 53%) !important; }
|
||||
.threads-border-blue { border-left-color: hsl(210 100% 55%) !important; }
|
||||
.threads-border-green { border-left-color: hsl(142 60% 45%) !important; }
|
||||
.threads-border-pink { border-left-color: hsl(340 80% 55%) !important; }
|
||||
.threads-border-purple { border-left-color: hsl(270 70% 55%) !important; }
|
||||
.threads-border-orange { border-left-color: hsl(28 100% 55%) !important; }
|
||||
|
||||
/* ========= Layout: fixed right sidebar column ========= */
|
||||
.tc-sidebar-column {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 40px;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.2s ease;
|
||||
font-family: var(--tc-font);
|
||||
}
|
||||
|
||||
body.tc-panel-open .tc-sidebar-column {
|
||||
width: 380px;
|
||||
}
|
||||
|
||||
body.tc-has-sidebar main {
|
||||
max-width: 1440px;
|
||||
}
|
||||
|
||||
body.tc-has-sidebar .main-content-wrapper {
|
||||
margin-right: 40px;
|
||||
transition: margin-right 0.2s ease;
|
||||
}
|
||||
body.tc-panel-open .main-content-wrapper {
|
||||
margin-right: 380px;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
body.tc-panel-open .toc-sidebar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========= Toggle strip ========= */
|
||||
.tc-sidebar-toggle {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: var(--tc-bg);
|
||||
color: var(--tc-muted-fg);
|
||||
border-left: 1px solid var(--tc-border);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
position: relative;
|
||||
padding: 16px 0 0 0;
|
||||
}
|
||||
.tc-sidebar-toggle:hover {
|
||||
background: var(--tc-accent);
|
||||
color: var(--tc-fg);
|
||||
}
|
||||
|
||||
body.tc-panel-open .tc-sidebar-toggle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ========= Panel ========= */
|
||||
.tc-panel {
|
||||
flex: 1;
|
||||
width: 380px;
|
||||
background: var(--tc-bg);
|
||||
border-left: 1px solid var(--tc-border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: tc-slide-in 0.2s ease;
|
||||
}
|
||||
@keyframes tc-slide-in {
|
||||
from { transform: translateX(100%); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
}
|
||||
.tc-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid var(--tc-border);
|
||||
}
|
||||
.tc-panel__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: var(--tc-fg);
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.tc-panel__header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.tc-panel__filters {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--tc-border);
|
||||
}
|
||||
.tc-panel__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* ========= Empty state ========= */
|
||||
.tc-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
color: var(--tc-muted-fg);
|
||||
padding: 48px 24px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ========= Thread (shadcn card) ========= */
|
||||
.tc-thread {
|
||||
margin: 0 8px 6px;
|
||||
border: 1px solid var(--tc-border);
|
||||
border-radius: var(--tc-radius);
|
||||
overflow: hidden;
|
||||
background: var(--tc-card);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.tc-thread:hover {
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
.tc-thread--focused {
|
||||
border-color: var(--tc-ring);
|
||||
box-shadow: 0 0 0 1px var(--tc-ring);
|
||||
}
|
||||
.tc-thread--resolved { opacity: 0.6; }
|
||||
.tc-thread__quote {
|
||||
padding: 10px 14px;
|
||||
border-left: 2px solid var(--tc-border);
|
||||
margin: 10px 14px 0;
|
||||
cursor: pointer;
|
||||
border-radius: 0;
|
||||
background: var(--tc-muted);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.tc-thread__quote:hover {
|
||||
background: color-mix(in srgb, var(--tc-muted) 80%, var(--tc-fg) 20%);
|
||||
}
|
||||
.tc-thread__quote-text {
|
||||
font-size: 13px;
|
||||
color: var(--tc-muted-fg);
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
.tc-thread__comments {
|
||||
padding: 2px 0;
|
||||
}
|
||||
.tc-thread__reply {
|
||||
border-top: 1px solid var(--tc-border);
|
||||
}
|
||||
.tc-thread__footer {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
padding: 6px 10px 8px;
|
||||
border-top: 1px solid var(--tc-border);
|
||||
}
|
||||
|
||||
/* ========= Comment ========= */
|
||||
.tc-comment {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.tc-comment + .tc-comment {
|
||||
border-top: 1px solid var(--tc-border);
|
||||
}
|
||||
.tc-comment__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.tc-comment__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.tc-comment__author {
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
color: var(--tc-fg);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.tc-comment__time {
|
||||
font-size: 11px;
|
||||
color: var(--tc-muted-fg);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.tc-comment__menu {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
.tc-comment:hover .tc-comment__menu {
|
||||
opacity: 1;
|
||||
}
|
||||
.tc-comment__body {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: var(--tc-fg);
|
||||
margin-left: 34px;
|
||||
}
|
||||
.tc-comment__body p { margin: 0 0 4px 0; }
|
||||
.tc-comment__body p:last-child { margin-bottom: 0; }
|
||||
.tc-comment__body code {
|
||||
background: var(--tc-muted);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tc-comment__body pre {
|
||||
background: var(--tc-muted);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--tc-radius);
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tc-comment__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: 34px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.tc-comment__actions {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
/* ========= Reactions ========= */
|
||||
.tc-reactions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.tc-reaction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--tc-border);
|
||||
background: var(--tc-bg);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.tc-reaction:hover {
|
||||
background: var(--tc-accent);
|
||||
border-color: var(--tc-input);
|
||||
}
|
||||
.tc-reaction--active {
|
||||
background: var(--tc-accent);
|
||||
border-color: var(--tc-fg);
|
||||
}
|
||||
.tc-reaction__count {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--tc-muted-fg);
|
||||
}
|
||||
|
||||
/* ========= Emoji picker item (used inside wa-popover) ========= */
|
||||
.tc-emoji-picker__item {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: var(--tc-radius);
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.tc-emoji-picker__item:hover {
|
||||
background: var(--tc-accent);
|
||||
}
|
||||
|
||||
/* ========= Compose ========= */
|
||||
.tc-compose {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.tc-compose__quote {
|
||||
padding: 8px 12px;
|
||||
border-left: 2px solid var(--tc-border);
|
||||
background: var(--tc-muted);
|
||||
border-radius: 0;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--tc-muted-fg);
|
||||
font-style: italic;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.tc-compose__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.tc-compose wa-textarea,
|
||||
.tc-thread__reply wa-textarea,
|
||||
.threads-thread wa-textarea {
|
||||
width: 100% !important;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Force wa-textarea internal textarea to expand */
|
||||
.tc-compose wa-textarea::part(base),
|
||||
.tc-compose wa-textarea::part(textarea),
|
||||
.tc-thread__reply wa-textarea::part(base),
|
||||
.tc-thread__reply wa-textarea::part(textarea),
|
||||
.threads-thread wa-textarea::part(base),
|
||||
.threads-thread wa-textarea::part(textarea) {
|
||||
width: 100% !important;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.threads-thread .tc-compose {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ========= New thread compose ========= */
|
||||
.tc-new-thread {
|
||||
margin: 0 8px 6px;
|
||||
border: 1px solid var(--tc-border);
|
||||
border-radius: var(--tc-radius);
|
||||
overflow: hidden;
|
||||
background: var(--tc-card);
|
||||
}
|
||||
|
||||
/* ========= Server-rendered threads wrapper ========= */
|
||||
.threads-sidebar {
|
||||
margin: 24px 0 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.threads-sidebar--labeled {
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--tc-border);
|
||||
}
|
||||
.threads-sidebar__heading {
|
||||
font-family: var(--tc-font);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--tc-muted-fg);
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* ========= Server-rendered thread card ========= */
|
||||
.threads-thread {
|
||||
margin: 12px 0;
|
||||
border: 1px solid var(--tc-border);
|
||||
border-left: 3px solid var(--tc-ring);
|
||||
border-radius: var(--tc-radius);
|
||||
background: var(--tc-card);
|
||||
overflow: hidden;
|
||||
font-family: var(--tc-font);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.threads-thread:hover {
|
||||
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.08), 0 1px 2px -1px rgb(0 0 0 / 0.08);
|
||||
}
|
||||
.threads-thread--resolved {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ========= Server-rendered comment ========= */
|
||||
.threads-comment {
|
||||
display: grid;
|
||||
grid-template-columns: 35px 1fr;
|
||||
grid-template-rows: 28px auto auto;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.threads-comment {
|
||||
column-gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/** .threads-comment + .threads-comment {
|
||||
border-top: 1px solid var(--tc-border);
|
||||
} */
|
||||
|
||||
/* Avatar column - row 1; vertical line spans rows 2-3 */
|
||||
.threads-comment__avatar-col {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.threads-comment__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.threads-comment__meta {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: var(--tc-muted-fg);
|
||||
gap: 5px;
|
||||
}
|
||||
.threads-comment__meta strong {
|
||||
color: var(--tc-fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.threads-comment__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.threads-comment__body {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
color: var(--tc-fg);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.threads-comment__body > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.threads-comment__body > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ========= Collapsed thread ========= */
|
||||
.threads-thread__summary {
|
||||
display: none;
|
||||
font-size: 13px;
|
||||
color: var(--tc-muted-fg);
|
||||
font-style: italic;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
.threads-thread--collapsed .threads-thread__summary {
|
||||
display: block;
|
||||
}
|
||||
.threads-thread--collapsed .threads-comment,
|
||||
.threads-thread--collapsed .threads-replies {
|
||||
display: none;
|
||||
}
|
||||
.threads-thread--collapsed .threads-new-comment-btn {
|
||||
display: none;
|
||||
}
|
||||
.threads-thread--collapsed .threads-thread__footer {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* ========= Thread footer & buttons ========= */
|
||||
.threads-thread__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px 8px;
|
||||
border-top: 1px solid var(--tc-border);
|
||||
}
|
||||
.threads-new-comment-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 4px 10px;
|
||||
border: 1px dashed var(--tc-border);
|
||||
background: transparent;
|
||||
color: var(--tc-muted-fg);
|
||||
font-family: var(--tc-font);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: var(--tc-radius);
|
||||
transition: color 0.15s, background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.threads-new-comment-btn:hover {
|
||||
color: var(--tc-fg);
|
||||
border-color: var(--tc-fg);
|
||||
background: var(--tc-muted);
|
||||
}
|
||||
|
||||
/* ========= Nested replies ========= */
|
||||
.threads-replies {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
/* Vertical connector line centered under avatar */
|
||||
.threads-comment:has(.threads-replies) > .threads-comment__avatar-col {
|
||||
grid-row: 1 / 4;
|
||||
align-items: flex-start;
|
||||
position: relative;
|
||||
}
|
||||
.threads-comment:has(.threads-replies) > .threads-comment__avatar-col::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 36px;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 2px;
|
||||
background: var(--tc-border);
|
||||
}
|
||||
|
||||
.threads-comment--reply {
|
||||
padding: 8px 0 !important;
|
||||
}
|
||||
.threads-comment--reply {
|
||||
grid-template-columns: 24px 1fr;
|
||||
grid-template-rows: 24px auto auto;
|
||||
}
|
||||
.threads-comment--reply .threads-comment__avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* ========= Per-comment reply button ========= */
|
||||
.threads-comment-reply-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--tc-muted-fg);
|
||||
font-family: var(--tc-font);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
border-radius: var(--tc-radius);
|
||||
}
|
||||
.threads-comment:hover .threads-comment-reply-btn {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.threads-comment-reply-btn:hover {
|
||||
opacity: 1 !important;
|
||||
color: var(--tc-fg);
|
||||
background: var(--tc-muted);
|
||||
}
|
||||
|
||||
/* ========= Server-rendered reactions ========= */
|
||||
.threads-reactions {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.threads-reactions ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.threads-reactions li {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 10px 4px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--tc-border);
|
||||
background: var(--tc-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
cursor: default;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
user-select: none;
|
||||
}
|
||||
.threads-reactions li:hover {
|
||||
background: var(--tc-accent);
|
||||
border-color: var(--tc-input);
|
||||
}
|
||||
|
||||
/* ========= Heading discussion button (hidden) ========= */
|
||||
.threads-heading-discuss { display: none !important; }
|
||||
.threads-heading-discuss-OFF {
|
||||
float: right;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: transparent;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: var(--tc-radius);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
vertical-align: middle;
|
||||
}
|
||||
*:hover > .threads-heading-discuss {
|
||||
color: var(--tc-muted-fg);
|
||||
}
|
||||
.threads-heading-discuss:hover {
|
||||
color: var(--tc-accent-fg);
|
||||
background: var(--tc-accent);
|
||||
}
|
||||
|
||||
/* ========= Heading wrapper for New Thread button ========= */
|
||||
.threads-heading-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.threads-heading-wrap .threads-new-thread-btn {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ========= New Thread button ========= */
|
||||
.threads-new-thread-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 3px 10px;
|
||||
font-family: var(--tc-font);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--tc-muted-fg);
|
||||
background: transparent;
|
||||
border: 1px dashed var(--tc-border);
|
||||
border-radius: var(--tc-radius);
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.threads-new-thread-btn:hover {
|
||||
color: var(--tc-fg);
|
||||
border-color: var(--tc-fg);
|
||||
background: var(--tc-muted);
|
||||
}
|
||||
|
||||
/* ========= Delete button (on comment meta) ========= */
|
||||
.threads-delete-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--tc-muted-fg);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, color 0.15s, background 0.15s;
|
||||
border-radius: var(--tc-radius);
|
||||
}
|
||||
.threads-comment:hover .threads-delete-btn {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.threads-delete-btn:hover {
|
||||
opacity: 1 !important;
|
||||
color: hsl(0 72% 51%);
|
||||
background: hsl(0 72% 51% / 0.08);
|
||||
}
|
||||
|
||||
/* ========= Collapse/expand toggle button ========= */
|
||||
.threads-collapse-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
padding: 3px 6px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--tc-muted-fg);
|
||||
cursor: pointer;
|
||||
border-radius: var(--tc-radius);
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
.threads-collapse-btn:hover {
|
||||
color: var(--tc-fg);
|
||||
background: var(--tc-muted);
|
||||
}
|
||||
|
||||
/* ========= Thread flash animation (on highlight click) ========= */
|
||||
.threads-thread--flash {
|
||||
animation: tc-flash 2s ease-out;
|
||||
}
|
||||
@keyframes tc-flash {
|
||||
0% { outline: 2px solid var(--tc-ring); outline-offset: 4px; }
|
||||
100% { outline: 2px solid transparent; outline-offset: 8px; }
|
||||
}
|
||||
|
||||
/* ========= Identity button & dropdown ========= */
|
||||
threads-identity {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-family: var(--tc-font);
|
||||
}
|
||||
.threads-identity-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--tc-border);
|
||||
background: var(--tc-card);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.threads-identity-btn:hover {
|
||||
border-color: var(--tc-ring);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--tc-ring) 20%, transparent);
|
||||
}
|
||||
.threads-identity-avatar {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.threads-identity-initial {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--tc-fg);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ========= Dropdown panel ========= */
|
||||
.threads-identity-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 260px;
|
||||
background: var(--tc-card);
|
||||
border: 1px solid var(--tc-border);
|
||||
border-radius: var(--tc-radius);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.12), 0 1px 3px rgb(0 0 0 / 0.08);
|
||||
padding: 14px;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
animation: tc-panel-drop 0.15s ease;
|
||||
}
|
||||
@keyframes tc-panel-drop {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.threads-identity-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.threads-identity-preview-img {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 2px solid var(--tc-border);
|
||||
}
|
||||
.threads-identity-preview-placeholder {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--tc-border);
|
||||
background: var(--tc-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--tc-muted-fg);
|
||||
}
|
||||
.threads-identity-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--tc-fg);
|
||||
}
|
||||
.threads-identity-input {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--tc-border);
|
||||
border-radius: var(--tc-radius);
|
||||
background: var(--tc-bg);
|
||||
color: var(--tc-fg);
|
||||
font-family: var(--tc-font);
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
.threads-identity-input:focus {
|
||||
border-color: var(--tc-ring);
|
||||
}
|
||||
.threads-identity-hint {
|
||||
font-size: 11px;
|
||||
color: var(--tc-muted-fg);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.threads-identity-hint a {
|
||||
color: var(--tc-muted-fg);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.threads-identity-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.threads-identity-cancel,
|
||||
.threads-identity-save {
|
||||
padding: 4px 12px;
|
||||
border-radius: var(--tc-radius);
|
||||
font-family: var(--tc-font);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.threads-identity-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--tc-border);
|
||||
color: var(--tc-muted-fg);
|
||||
}
|
||||
.threads-identity-cancel:hover {
|
||||
border-color: var(--tc-fg);
|
||||
color: var(--tc-fg);
|
||||
}
|
||||
.threads-identity-save {
|
||||
background: var(--tc-fg);
|
||||
border: 1px solid var(--tc-fg);
|
||||
color: var(--tc-bg);
|
||||
}
|
||||
.threads-identity-save:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ========= Sidebar column: hidden by default, shown via body.tc-sidebar-active ========= */
|
||||
.tc-sidebar-column { display: none; }
|
||||
body.tc-sidebar-active .tc-sidebar-column { display: flex; }
|
||||
body.tc-sidebar-active .threads-sidebar { display: none !important; }
|
||||
body.tc-sidebar-active .tc-has-sidebar { padding-right: 40px; }
|
||||
|
||||
/* ========= Floating threads toggle ========= */
|
||||
.threads-fab {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 999;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--tc-border);
|
||||
background: var(--tc-card);
|
||||
color: var(--tc-muted-fg);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.12), 0 1px 3px rgb(0 0 0 / 0.08);
|
||||
transition: color 0.15s, border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.threads-fab:hover {
|
||||
color: var(--tc-fg);
|
||||
border-color: var(--tc-ring);
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 4px 16px rgb(0 0 0 / 0.16), 0 2px 4px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
.threads-fab__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: hsl(210 100% 55%);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-align: center;
|
||||
font-family: var(--tc-font);
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
@@ -0,0 +1,871 @@
|
||||
import { LitElement, html } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import type { Thread, Anchor, AuthorsMap } from '../../types';
|
||||
import * as api from '../lib/api';
|
||||
import { initIdentity, getIdentityPayload } from '../lib/identity';
|
||||
import { computeAnchor, getSelectionPosition, isWithinContent } from '../lib/selection';
|
||||
import { initThemeBridge } from '../lib/theme';
|
||||
import { t } from '../lib/i18n';
|
||||
|
||||
import './threads-popover';
|
||||
import './threads-inline-editor';
|
||||
import './threads-identity';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/dialog/dialog.js';
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
|
||||
@customElement('threads-app')
|
||||
export class ThreadsApp extends LitElement {
|
||||
override createRenderRoot() { return this; }
|
||||
|
||||
@state() private threads: Thread[] = [];
|
||||
@state() private authorsMap: AuthorsMap = {};
|
||||
@state() private popoverActive = false;
|
||||
@state() private popoverX = 0;
|
||||
@state() private popoverY = 0;
|
||||
|
||||
private pendingAnchor: Anchor | null = null;
|
||||
private pendingBlockEl: HTMLElement | null = null;
|
||||
private deleteTarget: { type: 'thread' | 'comment'; id: string; threadId?: string } | null = null;
|
||||
private inlineEditorEl: HTMLElement | null = null;
|
||||
|
||||
/** Whether sidebar mode is enabled via plugin config. Default: false (inline mode). */
|
||||
private get sidebarEnabled(): boolean {
|
||||
return !!(window as any).__threads_config?.sidebar;
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('mouseup', this.handleMouseUp);
|
||||
document.removeEventListener('mousedown', this.handleOutsidePopoverClick);
|
||||
document.removeEventListener('docmd:page-mounted', this.handlePageMounted as EventListener);
|
||||
}
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
initThemeBridge();
|
||||
initIdentity();
|
||||
this.injectIdentityButton();
|
||||
document.addEventListener('mouseup', this.handleMouseUp);
|
||||
document.addEventListener('mousedown', this.handleOutsidePopoverClick);
|
||||
document.addEventListener('docmd:page-mounted', this.handlePageMounted as EventListener);
|
||||
|
||||
// Activate sidebar mode if configured
|
||||
if (this.sidebarEnabled) {
|
||||
this.initSidebarColumn();
|
||||
}
|
||||
|
||||
this.loadThreads();
|
||||
this.injectNewThreadButton();
|
||||
this.injectFab();
|
||||
|
||||
// Register afterReload handler for post-mutation continuity
|
||||
if (typeof docmd !== 'undefined' && docmd.afterReload) {
|
||||
docmd.afterReload('threads', () => {
|
||||
this.loadThreads();
|
||||
this.injectNewThreadButton();
|
||||
this.updateFabBadge();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the content area of the page.
|
||||
*/
|
||||
private getContentArea(): Element | null {
|
||||
return document.querySelector('[data-docmd-content]')
|
||||
|| document.querySelector('.docmd-content')
|
||||
|| document.querySelector('.main-content')
|
||||
|| document.querySelector('article')
|
||||
|| document.querySelector('main');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the identity button and place it in the page header.
|
||||
* Done imperatively so Lit re-renders don't pull it back into threads-app.
|
||||
*/
|
||||
private injectIdentityButton(): void {
|
||||
// Avoid duplicates
|
||||
if (document.querySelector('threads-identity')) return;
|
||||
|
||||
const target = document.querySelector('.docmd-options-menu')
|
||||
|| document.querySelector('.header-right');
|
||||
if (!target) return;
|
||||
|
||||
const identity = document.createElement('threads-identity');
|
||||
target.appendChild(identity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a "New Thread" button into every heading in the content area.
|
||||
* Each button is right-aligned on the same line as the heading text.
|
||||
*/
|
||||
private injectNewThreadButton(): void {
|
||||
// Remove existing buttons (e.g. after reload)
|
||||
document.querySelectorAll('.threads-new-thread-btn').forEach(el => el.remove());
|
||||
// Remove wrapper classes from headings
|
||||
document.querySelectorAll('.threads-heading-wrap').forEach(el => {
|
||||
el.classList.remove('threads-heading-wrap');
|
||||
});
|
||||
|
||||
const contentArea = this.getContentArea();
|
||||
if (!contentArea) return;
|
||||
|
||||
const HEADING_TAGS = new Set(['H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
|
||||
// Only target direct children of the content area - avoids TOC, sidebar headings, etc.
|
||||
const headings = Array.from(contentArea.children).filter(el => HEADING_TAGS.has(el.tagName));
|
||||
|
||||
for (const heading of headings) {
|
||||
// Only target direct-ish headings inside the content area
|
||||
if (!HEADING_TAGS.has(heading.tagName)) continue;
|
||||
|
||||
heading.classList.add('threads-heading-wrap');
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'threads-new-thread-btn';
|
||||
const icon = document.createElement('wa-icon');
|
||||
icon.setAttribute('name', 'plus');
|
||||
icon.setAttribute('style', 'font-size:14px;');
|
||||
btn.appendChild(icon);
|
||||
btn.appendChild(document.createTextNode(' ' + t('newThread')));
|
||||
btn.title = t('startThread');
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.startNewThread(heading);
|
||||
});
|
||||
heading.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new top-level thread by opening an inline editor after the given heading.
|
||||
*/
|
||||
private startNewThread(heading: Element): void {
|
||||
this.removeInlineEditor();
|
||||
|
||||
const editor = document.createElement('threads-inline-editor') as any;
|
||||
editor.quote = '';
|
||||
|
||||
editor.addEventListener('inline-submit', async (e: CustomEvent) => {
|
||||
const identity = getIdentityPayload();
|
||||
try {
|
||||
await api.createThread({
|
||||
anchor: null,
|
||||
...identity,
|
||||
body: e.detail.body,
|
||||
});
|
||||
this.removeInlineEditor();
|
||||
if (typeof docmd !== 'undefined' && docmd.scheduleReload) {
|
||||
docmd.scheduleReload('threads');
|
||||
} else {
|
||||
await this.loadThreads();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[threads] Failed to create thread:', err);
|
||||
editor.submitting = false;
|
||||
}
|
||||
});
|
||||
|
||||
editor.addEventListener('inline-cancel', () => this.removeInlineEditor());
|
||||
|
||||
heading.insertAdjacentElement('afterend', editor);
|
||||
this.inlineEditorEl = editor;
|
||||
}
|
||||
|
||||
// ─── Selection popover ────────────────────────────────────────────
|
||||
|
||||
private handleMouseUp = (e: MouseEvent): void => {
|
||||
const popover = this.querySelector('threads-popover');
|
||||
if (popover?.contains(e.target as Node)) return;
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (!selection || selection.isCollapsed) {
|
||||
this.popoverActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selection.anchorNode || !isWithinContent(selection.anchorNode)) return;
|
||||
|
||||
const anchor = computeAnchor(selection);
|
||||
if (!anchor) return;
|
||||
|
||||
const pos = getSelectionPosition(selection);
|
||||
if (!pos) return;
|
||||
|
||||
// Find the enclosing block element for inline editor insertion
|
||||
const BLOCK_TAGS = new Set(["P", "DIV", "LI", "BLOCKQUOTE", "PRE", "H1", "H2", "H3", "H4", "H5", "H6"]);
|
||||
let blockEl: HTMLElement | null = null;
|
||||
let node: Node | null = selection.getRangeAt(0).startContainer;
|
||||
while (node && node !== document.body) {
|
||||
if (node instanceof HTMLElement && BLOCK_TAGS.has(node.tagName)) {
|
||||
blockEl = node;
|
||||
break;
|
||||
}
|
||||
node = node.parentNode;
|
||||
}
|
||||
|
||||
this.pendingAnchor = anchor;
|
||||
this.pendingBlockEl = blockEl;
|
||||
this.popoverX = pos.x;
|
||||
this.popoverY = pos.y;
|
||||
this.popoverActive = true;
|
||||
};
|
||||
|
||||
private handleOutsidePopoverClick = (e: MouseEvent): void => {
|
||||
const popover = this.querySelector('threads-popover');
|
||||
if (popover && !e.composedPath().includes(popover)) {
|
||||
this.popoverActive = false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle popover "add comment" - open inline editor after the block.
|
||||
*/
|
||||
private handleAddComment(): void {
|
||||
if (!this.pendingAnchor || !this.pendingBlockEl) return;
|
||||
|
||||
this.removeInlineEditor();
|
||||
this.popoverActive = false;
|
||||
|
||||
const anchor = this.pendingAnchor;
|
||||
const blockEl = this.pendingBlockEl;
|
||||
this.pendingAnchor = null;
|
||||
this.pendingBlockEl = null;
|
||||
window.getSelection()?.removeAllRanges();
|
||||
|
||||
const editor = document.createElement('threads-inline-editor') as any;
|
||||
editor.quote = anchor.quote || '';
|
||||
|
||||
editor.addEventListener('inline-submit', async (e: CustomEvent) => {
|
||||
const identity = getIdentityPayload();
|
||||
try {
|
||||
await api.createThread({
|
||||
anchor,
|
||||
...identity,
|
||||
body: e.detail.body,
|
||||
});
|
||||
this.removeInlineEditor();
|
||||
if (typeof docmd !== 'undefined' && docmd.scheduleReload) {
|
||||
docmd.scheduleReload('threads');
|
||||
} else {
|
||||
await this.loadThreads();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[threads] Failed to create thread:', err);
|
||||
editor.submitting = false;
|
||||
}
|
||||
});
|
||||
|
||||
editor.addEventListener('inline-cancel', () => this.removeInlineEditor());
|
||||
|
||||
blockEl.insertAdjacentElement('afterend', editor);
|
||||
this.inlineEditorEl = editor;
|
||||
}
|
||||
|
||||
// ─── Page lifecycle ───────────────────────────────────────────────
|
||||
|
||||
private handlePageMounted = (_e: CustomEvent): void => {
|
||||
this.popoverActive = false;
|
||||
this.loadThreads();
|
||||
this.injectNewThreadButton();
|
||||
};
|
||||
|
||||
private async loadThreads(): Promise<void> {
|
||||
try {
|
||||
const [threads, authors] = await Promise.all([
|
||||
api.fetchThreads(),
|
||||
api.fetchAuthors(),
|
||||
]);
|
||||
this.threads = threads;
|
||||
this.authorsMap = authors;
|
||||
} catch (err) {
|
||||
console.error('[threads] Failed to load threads:', err);
|
||||
this.threads = [];
|
||||
}
|
||||
this.scanRenderedHighlights();
|
||||
this.updateFabBadge();
|
||||
}
|
||||
|
||||
// Color palette for highlights - cycles through these
|
||||
private static HIGHLIGHT_COLORS = [
|
||||
'threads-hl-yellow',
|
||||
'threads-hl-blue',
|
||||
'threads-hl-green',
|
||||
'threads-hl-pink',
|
||||
'threads-hl-purple',
|
||||
'threads-hl-orange',
|
||||
];
|
||||
|
||||
/**
|
||||
* Scan the DOM for <mark class="threads-highlight" data-thread-id="..."> elements.
|
||||
* Assigns cycling highlight colors, moves thread cards inline after the block
|
||||
* containing the highlight, and attaches click handlers.
|
||||
*/
|
||||
private scanRenderedHighlights(): void {
|
||||
const marks = document.querySelectorAll<HTMLElement>('mark.threads-highlight[data-thread-id]');
|
||||
const BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'PRE', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'TABLE']);
|
||||
let colorIndex = 0;
|
||||
|
||||
for (const mark of marks) {
|
||||
const threadId = mark.dataset.threadId;
|
||||
if (!threadId) continue;
|
||||
|
||||
// 1. Assign cycling highlight color
|
||||
const colorClass = ThreadsApp.HIGHLIGHT_COLORS[colorIndex % ThreadsApp.HIGHLIGHT_COLORS.length];
|
||||
mark.classList.add(colorClass);
|
||||
colorIndex++;
|
||||
|
||||
// 2. Apply matching border color to thread card (always, regardless of mode)
|
||||
const threadEl = document.querySelector<HTMLElement>(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
if (threadEl) {
|
||||
threadEl.classList.add(colorClass.replace('threads-hl-', 'threads-border-'));
|
||||
|
||||
// 3. Move thread card inline only when sidebar mode is OFF (the default)
|
||||
if (!this.sidebarEnabled) {
|
||||
let blockEl: Element | null = mark;
|
||||
while (blockEl && blockEl !== document.body) {
|
||||
if (blockEl instanceof HTMLElement && BLOCK_TAGS.has(blockEl.tagName)) {
|
||||
break;
|
||||
}
|
||||
blockEl = blockEl.parentElement;
|
||||
}
|
||||
|
||||
if (blockEl && blockEl !== document.body) {
|
||||
blockEl.insertAdjacentElement('afterend', threadEl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Click handler: scroll to thread and flash
|
||||
mark.style.cursor = 'pointer';
|
||||
mark.addEventListener('click', () => {
|
||||
const el = document.querySelector(`.threads-thread[data-thread-id="${threadId}"]`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('threads-thread--flash');
|
||||
setTimeout(() => el.classList.remove('threads-thread--flash'), 2000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Inject reply buttons into all thread cards
|
||||
this.injectReplyButtons();
|
||||
|
||||
// Handle sidebar visibility based on mode
|
||||
const sidebar = document.querySelector('.threads-sidebar');
|
||||
if (!this.sidebarEnabled) {
|
||||
// Inline mode: hide the sidebar if all threads were moved out
|
||||
if (sidebar instanceof HTMLElement) {
|
||||
const remainingThreads = sidebar.querySelectorAll('.threads-thread');
|
||||
if (remainingThreads.length === 0) {
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Sidebar mode: move thread cards into the right panel
|
||||
this.populateSidebarPanel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhance thread cards: nest replies under parents, add per-comment reply & delete buttons,
|
||||
* and a "+ New Comment" footer button for top-level comments.
|
||||
*/
|
||||
private injectReplyButtons(): void {
|
||||
const threads = document.querySelectorAll<HTMLElement>('.threads-thread[data-thread-id]');
|
||||
for (const threadEl of threads) {
|
||||
// Skip if already enhanced
|
||||
if (threadEl.querySelector('.threads-new-comment-btn')) continue;
|
||||
|
||||
const threadId = threadEl.dataset.threadId;
|
||||
if (!threadId) continue;
|
||||
|
||||
// Nest replies under their parent comments
|
||||
this.nestReplies(threadEl);
|
||||
|
||||
// Add per-comment reply + delete buttons
|
||||
const allComments = threadEl.querySelectorAll<HTMLElement>('.threads-comment');
|
||||
const totalComments = allComments.length;
|
||||
for (const commentEl of allComments) {
|
||||
const commentId = commentEl.dataset.commentId;
|
||||
if (!commentId) continue;
|
||||
|
||||
const meta = commentEl.querySelector('.threads-comment__meta');
|
||||
if (!meta) continue;
|
||||
|
||||
// Inject avatar into the avatar column if not already present
|
||||
const avatarCol = commentEl.querySelector('.threads-comment__avatar-col');
|
||||
if (avatarCol && !avatarCol.querySelector('.threads-comment__avatar')) {
|
||||
const authorVal = commentEl.dataset.author || '';
|
||||
const authorInfo = this.resolveAuthor(authorVal);
|
||||
if (authorInfo?.avatarUrl) {
|
||||
const avatar = document.createElement('img');
|
||||
avatar.className = 'threads-comment__avatar';
|
||||
avatar.src = authorInfo.avatarUrl;
|
||||
avatar.alt = authorInfo.name || authorVal;
|
||||
avatarCol.appendChild(avatar);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if actions already added (re-render)
|
||||
if (commentEl.querySelector('.threads-comment__actions')) continue;
|
||||
|
||||
// Actions container - pushed to the right via margin-left: auto
|
||||
const actions = document.createElement('div');
|
||||
actions.className = 'threads-comment__actions';
|
||||
|
||||
// Reply button
|
||||
const replyBtn = document.createElement('button');
|
||||
replyBtn.className = 'threads-comment-reply-btn';
|
||||
const replyIcon = document.createElement('wa-icon');
|
||||
replyIcon.setAttribute('name', 'reply');
|
||||
replyIcon.setAttribute('style', 'font-size:13px;');
|
||||
replyBtn.appendChild(replyIcon);
|
||||
replyBtn.appendChild(document.createTextNode(' ' + t('reply')));
|
||||
replyBtn.title = t('replyToComment');
|
||||
replyBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.openReplyEditor(threadEl, threadId, commentId);
|
||||
});
|
||||
actions.appendChild(replyBtn);
|
||||
|
||||
// Delete button
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.className = 'threads-delete-btn';
|
||||
const delIcon = document.createElement('wa-icon');
|
||||
delIcon.setAttribute('name', 'trash');
|
||||
delIcon.setAttribute('style', 'font-size:13px;');
|
||||
delBtn.appendChild(delIcon);
|
||||
delBtn.title = t('deleteComment');
|
||||
delBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (totalComments === 1) {
|
||||
this.deleteTarget = { type: 'thread', id: threadId };
|
||||
} else {
|
||||
this.deleteTarget = { type: 'comment', id: commentId, threadId };
|
||||
}
|
||||
const dialog = this.querySelector<HTMLElement & { open: boolean }>('#delete-dialog');
|
||||
if (dialog) dialog.open = true;
|
||||
});
|
||||
actions.appendChild(delBtn);
|
||||
|
||||
meta.appendChild(actions);
|
||||
}
|
||||
|
||||
// Build collapse summary
|
||||
const allCommentsForSummary = threadEl.querySelectorAll<HTMLElement>('.threads-comment');
|
||||
const summary = this.buildCollapseSummary(allCommentsForSummary);
|
||||
|
||||
// Footer with summary (hidden when expanded), "+ New Comment", and collapse toggle
|
||||
const footer = document.createElement('div');
|
||||
footer.className = 'threads-thread__footer';
|
||||
|
||||
// Summary element (visible only when collapsed, via CSS)
|
||||
const summaryEl = document.createElement('div');
|
||||
summaryEl.className = 'threads-thread__summary';
|
||||
summaryEl.textContent = summary;
|
||||
footer.appendChild(summaryEl);
|
||||
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'threads-new-comment-btn';
|
||||
const plusIcon = document.createElement('wa-icon');
|
||||
plusIcon.setAttribute('name', 'plus');
|
||||
plusIcon.setAttribute('style', 'font-size:13px;');
|
||||
btn.appendChild(plusIcon);
|
||||
btn.appendChild(document.createTextNode(' ' + t('newComment')));
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.openReplyEditor(threadEl, threadId, null);
|
||||
});
|
||||
footer.appendChild(btn);
|
||||
|
||||
// Collapse/expand toggle button
|
||||
const toggleBtn = document.createElement('button');
|
||||
toggleBtn.className = 'threads-collapse-btn';
|
||||
const upIcon = document.createElement('wa-icon');
|
||||
upIcon.setAttribute('name', 'chevron-up');
|
||||
upIcon.setAttribute('style', 'font-size:14px;');
|
||||
toggleBtn.appendChild(upIcon);
|
||||
toggleBtn.title = t('collapseThread');
|
||||
toggleBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const isCollapsed = threadEl.classList.toggle('threads-thread--collapsed');
|
||||
toggleBtn.innerHTML = '';
|
||||
const icon = document.createElement('wa-icon');
|
||||
icon.setAttribute('name', isCollapsed ? 'chevron-down' : 'chevron-up');
|
||||
icon.setAttribute('style', 'font-size:14px;');
|
||||
toggleBtn.appendChild(icon);
|
||||
toggleBtn.title = isCollapsed ? t('expandThread') : t('collapseThread');
|
||||
});
|
||||
footer.appendChild(toggleBtn);
|
||||
|
||||
threadEl.appendChild(footer);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a summary string like "3 comments by Alice, Bob, and 1 more".
|
||||
* Uses only the first name of each author.
|
||||
*/
|
||||
private buildCollapseSummary(comments: NodeListOf<HTMLElement>): string {
|
||||
const count = comments.length;
|
||||
const authors = new Set<string>();
|
||||
for (const c of comments) {
|
||||
const author = c.dataset.author;
|
||||
if (author) {
|
||||
const firstName = author.split(/\s+/)[0];
|
||||
authors.add(firstName);
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueNames = Array.from(authors);
|
||||
const MAX_SHOWN = 3;
|
||||
let byPart: string;
|
||||
|
||||
if (uniqueNames.length === 0) {
|
||||
byPart = '';
|
||||
} else if (uniqueNames.length <= MAX_SHOWN) {
|
||||
if (uniqueNames.length === 1) {
|
||||
byPart = ` ${t('by')} ${uniqueNames[0]}`;
|
||||
} else if (uniqueNames.length === 2) {
|
||||
byPart = ` ${t('by')} ${uniqueNames[0]} ${t('and')} ${uniqueNames[1]}`;
|
||||
} else {
|
||||
byPart = ` ${t('by')} ${uniqueNames.slice(0, -1).join(', ')}, ${t('and')} ${uniqueNames[uniqueNames.length - 1]}`;
|
||||
}
|
||||
} else {
|
||||
const shown = uniqueNames.slice(0, MAX_SHOWN);
|
||||
const remaining = uniqueNames.length - MAX_SHOWN;
|
||||
byPart = ` ${t('by')} ${shown.join(', ')}, ${t('and')} ${remaining} ${t('more')}`;
|
||||
}
|
||||
|
||||
return `${count} ${count === 1 ? t('commentCount') : t('commentsCount')}${byPart}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorganize flat comment elements into a nested structure.
|
||||
* Comments with data-parent-id get moved into a .threads-replies container
|
||||
* after their parent comment.
|
||||
*/
|
||||
private nestReplies(threadEl: HTMLElement): void {
|
||||
const comments = Array.from(threadEl.querySelectorAll<HTMLElement>('.threads-comment'));
|
||||
// Build a map of comment ID → element
|
||||
const commentMap = new Map<string, HTMLElement>();
|
||||
for (const c of comments) {
|
||||
const id = c.dataset.commentId;
|
||||
if (id) commentMap.set(id, c);
|
||||
}
|
||||
|
||||
// Move replies under their parents
|
||||
for (const commentEl of comments) {
|
||||
const parentId = commentEl.dataset.parentId;
|
||||
if (!parentId) continue;
|
||||
|
||||
const parentEl = commentMap.get(parentId);
|
||||
if (!parentEl) continue;
|
||||
|
||||
// Ensure the parent has a replies container
|
||||
let repliesContainer = parentEl.querySelector('.threads-replies') as HTMLElement;
|
||||
if (!repliesContainer) {
|
||||
repliesContainer = document.createElement('div');
|
||||
repliesContainer.className = 'threads-replies';
|
||||
parentEl.appendChild(repliesContainer);
|
||||
}
|
||||
|
||||
commentEl.classList.add('threads-comment--reply');
|
||||
repliesContainer.appendChild(commentEl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an inline editor for replying to a comment or adding a new top-level comment.
|
||||
* @param parentCommentId - null for top-level comment, or comment ID to reply to
|
||||
*/
|
||||
private openReplyEditor(threadEl: HTMLElement, threadId: string, parentCommentId: string | null): void {
|
||||
this.removeInlineEditor();
|
||||
|
||||
const editor = document.createElement('threads-inline-editor') as any;
|
||||
editor.quote = '';
|
||||
|
||||
editor.addEventListener('inline-submit', async (e: CustomEvent) => {
|
||||
const identity = getIdentityPayload();
|
||||
try {
|
||||
await api.addComment(threadId, {
|
||||
...identity,
|
||||
body: e.detail.body,
|
||||
parentId: parentCommentId,
|
||||
});
|
||||
this.removeInlineEditor();
|
||||
if (typeof docmd !== 'undefined' && docmd.scheduleReload) {
|
||||
docmd.scheduleReload('threads');
|
||||
} else {
|
||||
await this.loadThreads();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[threads] Failed to add comment:', err);
|
||||
editor.submitting = false;
|
||||
}
|
||||
});
|
||||
|
||||
editor.addEventListener('inline-cancel', () => this.removeInlineEditor());
|
||||
|
||||
if (parentCommentId) {
|
||||
// Insert editor after the specific comment (or its replies container)
|
||||
const parentComment = threadEl.querySelector(`.threads-comment[data-comment-id="${parentCommentId}"]`);
|
||||
if (parentComment) {
|
||||
const repliesContainer = parentComment.querySelector('.threads-replies');
|
||||
if (repliesContainer) {
|
||||
repliesContainer.appendChild(editor);
|
||||
} else {
|
||||
const bodyEl = parentComment.querySelector('.threads-comment__body');
|
||||
if (bodyEl) {
|
||||
bodyEl.appendChild(editor);
|
||||
} else {
|
||||
parentComment.appendChild(editor);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
threadEl.appendChild(editor);
|
||||
}
|
||||
} else {
|
||||
// Top-level: insert before the footer
|
||||
const footer = threadEl.querySelector('.threads-thread__footer');
|
||||
if (footer) {
|
||||
threadEl.insertBefore(editor, footer);
|
||||
} else {
|
||||
threadEl.appendChild(editor);
|
||||
}
|
||||
}
|
||||
this.inlineEditorEl = editor;
|
||||
}
|
||||
|
||||
// ─── Author resolution ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Look up author info by key or display name.
|
||||
* Tries direct key match first, then scans by display name for legacy comments.
|
||||
*/
|
||||
private resolveAuthor(authorVal: string): { name: string; avatarUrl: string } | null {
|
||||
// Direct key match
|
||||
if (this.authorsMap[authorVal]) return this.authorsMap[authorVal];
|
||||
|
||||
// Fallback: match by display name (legacy comments store full name)
|
||||
for (const info of Object.values(this.authorsMap)) {
|
||||
if (info.name === authorVal) return info;
|
||||
}
|
||||
|
||||
// Fallback: match by first name (comments may only show first name)
|
||||
for (const info of Object.values(this.authorsMap)) {
|
||||
if (info.name.split(/\s+/)[0] === authorVal) return info;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Inline editor helpers ────────────────────────────────────────
|
||||
|
||||
private removeInlineEditor(): void {
|
||||
this.inlineEditorEl?.remove();
|
||||
this.inlineEditorEl = null;
|
||||
}
|
||||
|
||||
// ─── Sidebar column ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create the fixed right sidebar column with toggle strip + panel.
|
||||
* Only called when sidebar config is enabled.
|
||||
*/
|
||||
private initSidebarColumn(): void {
|
||||
if (document.querySelector('.tc-sidebar-column')) return;
|
||||
|
||||
// Activate sidebar mode via body class
|
||||
document.body.classList.add('tc-sidebar-active', 'tc-has-sidebar');
|
||||
|
||||
// Create sidebar column
|
||||
const column = document.createElement('div');
|
||||
column.className = 'tc-sidebar-column';
|
||||
|
||||
// Toggle strip (collapsed state)
|
||||
const toggle = document.createElement('button');
|
||||
toggle.className = 'tc-sidebar-toggle';
|
||||
toggle.title = t('openThreadsPanel');
|
||||
const toggleIcon = document.createElement('wa-icon');
|
||||
toggleIcon.setAttribute('name', 'comments');
|
||||
toggleIcon.setAttribute('variant', 'regular');
|
||||
toggleIcon.setAttribute('style', 'font-size:16px;');
|
||||
toggle.appendChild(toggleIcon);
|
||||
toggle.addEventListener('click', () => {
|
||||
document.body.classList.add('tc-panel-open');
|
||||
});
|
||||
column.appendChild(toggle);
|
||||
|
||||
// Panel (expanded state)
|
||||
const panel = document.createElement('div');
|
||||
panel.className = 'tc-panel';
|
||||
|
||||
// Panel header
|
||||
const header = document.createElement('div');
|
||||
header.classList.add('tc-panel__header');
|
||||
|
||||
const titleDiv = document.createElement('div');
|
||||
titleDiv.className = 'tc-panel__title';
|
||||
const titleSpan = document.createElement('span');
|
||||
titleSpan.textContent = t('threads');
|
||||
const countSpan = document.createElement('span');
|
||||
countSpan.className = 'tc-panel__count';
|
||||
titleDiv.appendChild(titleSpan);
|
||||
titleDiv.appendChild(countSpan);
|
||||
|
||||
const actionsDiv = document.createElement('div');
|
||||
actionsDiv.className = 'tc-panel__header-actions';
|
||||
const cBtn = document.createElement('button');
|
||||
cBtn.className = 'tc-panel__close-btn';
|
||||
cBtn.title = t('closePanel');
|
||||
cBtn.style.cssText = 'background:none;border:none;cursor:pointer;color:var(--tc-muted-fg);padding:4px;';
|
||||
const closeIcon = document.createElement('wa-icon');
|
||||
closeIcon.setAttribute('name', 'xmark');
|
||||
closeIcon.setAttribute('variant', 'solid');
|
||||
closeIcon.setAttribute('style', 'font-size:16px;');
|
||||
cBtn.appendChild(closeIcon);
|
||||
actionsDiv.appendChild(cBtn);
|
||||
|
||||
header.appendChild(titleDiv);
|
||||
header.appendChild(actionsDiv);
|
||||
const closeBtn = header.querySelector('.tc-panel__close-btn');
|
||||
if (closeBtn) {
|
||||
closeBtn.addEventListener('click', () => {
|
||||
document.body.classList.remove('tc-panel-open');
|
||||
});
|
||||
}
|
||||
panel.appendChild(header);
|
||||
|
||||
// Panel body (thread cards go here)
|
||||
const body = document.createElement('div');
|
||||
body.className = 'tc-panel__body';
|
||||
panel.appendChild(body);
|
||||
|
||||
column.appendChild(panel);
|
||||
document.body.appendChild(column);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move thread cards from the bottom .threads-sidebar into the sidebar panel body.
|
||||
*/
|
||||
private populateSidebarPanel(): void {
|
||||
const panelBody = document.querySelector('.tc-panel__body');
|
||||
if (!panelBody) return;
|
||||
|
||||
// Clear existing panel threads
|
||||
panelBody.textContent = '';
|
||||
|
||||
// Move all thread cards into the panel
|
||||
const threadCards = document.querySelectorAll('.threads-sidebar .threads-thread');
|
||||
for (const card of threadCards) {
|
||||
panelBody.appendChild(card);
|
||||
}
|
||||
|
||||
// Update count
|
||||
const countEl = document.querySelector('.tc-panel__count');
|
||||
if (countEl) {
|
||||
const openCount = this.threads.filter(t => !t.resolved).length;
|
||||
countEl.textContent = openCount > 0 ? `(${openCount} ${t('openCount')})` : '';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Floating action button ──────────────────────────────────────
|
||||
|
||||
private injectFab(): void {
|
||||
if (document.querySelector('.threads-fab')) return;
|
||||
|
||||
const fab = document.createElement('button');
|
||||
fab.className = 'threads-fab';
|
||||
fab.title = t('jumpToThreads');
|
||||
const fabIcon = document.createElement('wa-icon');
|
||||
fabIcon.setAttribute('name', 'comments');
|
||||
fabIcon.setAttribute('variant', 'regular');
|
||||
fabIcon.setAttribute('style', 'font-size:20px;');
|
||||
fab.appendChild(fabIcon);
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'threads-fab__badge';
|
||||
badge.style.display = 'none';
|
||||
fab.appendChild(badge);
|
||||
|
||||
fab.addEventListener('click', () => {
|
||||
const firstThread = document.querySelector('.threads-thread, .threads-sidebar');
|
||||
if (firstThread) {
|
||||
firstThread.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (firstThread.classList.contains('threads-thread')) {
|
||||
firstThread.classList.add('threads-thread--flash');
|
||||
setTimeout(() => firstThread.classList.remove('threads-thread--flash'), 2000);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(fab);
|
||||
}
|
||||
|
||||
private updateFabBadge(): void {
|
||||
const badge = document.querySelector('.threads-fab__badge') as HTMLElement | null;
|
||||
if (!badge) return;
|
||||
const count = this.threads.filter(t => !t.resolved).length;
|
||||
if (count > 0) {
|
||||
badge.textContent = String(count);
|
||||
badge.style.display = '';
|
||||
} else {
|
||||
badge.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Delete confirmation ──────────────────────────────────────────
|
||||
|
||||
private handleDeleteRequest(e: CustomEvent, type: 'thread' | 'comment'): void {
|
||||
const id = type === 'thread' ? e.detail.threadId : e.detail.commentId;
|
||||
const threadId = type === 'comment' ? e.detail.threadId : undefined;
|
||||
this.deleteTarget = { type, id, threadId };
|
||||
const dialog = this.querySelector<HTMLElement & { open: boolean }>('#delete-dialog');
|
||||
if (dialog) dialog.open = true;
|
||||
}
|
||||
|
||||
private async confirmDelete(): Promise<void> {
|
||||
const dialog = this.querySelector<HTMLElement & { open: boolean }>('#delete-dialog');
|
||||
if (dialog) dialog.open = false;
|
||||
|
||||
if (!this.deleteTarget) return;
|
||||
if (this.deleteTarget.type === 'thread') {
|
||||
await api.deleteThread(this.deleteTarget.id);
|
||||
} else {
|
||||
await api.deleteComment(this.deleteTarget.threadId!, this.deleteTarget.id);
|
||||
}
|
||||
this.deleteTarget = null;
|
||||
if (typeof docmd !== 'undefined' && docmd.scheduleReload) {
|
||||
docmd.scheduleReload('threads');
|
||||
} else {
|
||||
await this.loadThreads();
|
||||
}
|
||||
}
|
||||
|
||||
private cancelDelete(): void {
|
||||
const dialog = this.querySelector<HTMLElement & { open: boolean }>('#delete-dialog');
|
||||
if (dialog) dialog.open = false;
|
||||
this.deleteTarget = null;
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<threads-popover
|
||||
?active=${this.popoverActive}
|
||||
.x=${this.popoverX}
|
||||
.y=${this.popoverY}
|
||||
@add-comment=${this.handleAddComment}
|
||||
></threads-popover>
|
||||
|
||||
<wa-dialog id="delete-dialog" label=${t('confirmDelete')} light-dismiss>
|
||||
${t('confirmDeleteMessage')} ${this.deleteTarget?.type ?? 'item'}?
|
||||
<wa-button slot="footer" appearance="outlined" @click=${this.cancelDelete}>${t('cancel')}</wa-button>
|
||||
<wa-button slot="footer" variant="danger" @click=${this.confirmDelete}>${t('delete')}</wa-button>
|
||||
</wa-dialog>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import type { Comment } from '../../types';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@awesome.me/webawesome/dist/components/textarea/textarea.js';
|
||||
import '@awesome.me/webawesome/dist/components/avatar/avatar.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/popover/popover.js';
|
||||
|
||||
const EMOJI_PRESETS = ['\u{1F44D}', '\u{1F44E}', '\u{1F602}', '\u{1F389}', '\u{1F914}', '\u{2764}\u{FE0F}', '\u{1F680}', '\u{1F440}'];
|
||||
|
||||
@customElement('threads-comment')
|
||||
export class ThreadsComment extends LitElement {
|
||||
override createRenderRoot() { return this; }
|
||||
|
||||
@property({ type: Object }) comment!: Comment;
|
||||
@property({ type: String }) currentAuthor: string | null = null;
|
||||
@property({ type: Boolean }) editing = false;
|
||||
@state() private showEmojiPicker = false;
|
||||
|
||||
private editValue = '';
|
||||
|
||||
private get isOwn(): boolean {
|
||||
return this.comment.author === this.currentAuthor;
|
||||
}
|
||||
|
||||
private formatTime(dateStr: string): string {
|
||||
const d = new Date(dateStr);
|
||||
const diff = Date.now() - d.getTime();
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}d ago`;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
private startEdit(): void {
|
||||
this.editValue = this.comment.body;
|
||||
this.editing = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private cancelEdit(): void {
|
||||
this.editing = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private saveEdit(): void {
|
||||
const textarea = this.querySelector<HTMLElement & { value: string }>('wa-textarea');
|
||||
const value = textarea?.value?.trim();
|
||||
if (!value) return;
|
||||
this.dispatchEvent(new CustomEvent('comment-edit', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { commentId: this.comment.id, threadId: this.comment.thread_id, body: value },
|
||||
}));
|
||||
this.editing = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private requestDelete(): void {
|
||||
this.dispatchEvent(new CustomEvent('comment-delete', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { commentId: this.comment.id, threadId: this.comment.thread_id },
|
||||
}));
|
||||
}
|
||||
|
||||
private toggleEmojiPicker(): void {
|
||||
this.showEmojiPicker = !this.showEmojiPicker;
|
||||
}
|
||||
|
||||
private addReaction(emoji: string): void {
|
||||
this.showEmojiPicker = false;
|
||||
this.dispatchEvent(new CustomEvent('comment-reaction', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { commentId: this.comment.id, threadId: this.comment.thread_id, emoji },
|
||||
}));
|
||||
}
|
||||
|
||||
private renderBody() {
|
||||
if (this.editing) {
|
||||
return html`
|
||||
<wa-textarea
|
||||
value=${this.editValue}
|
||||
rows="3"
|
||||
size="small"
|
||||
@input=${(e: Event) => { this.editValue = (e.target as any).value; }}
|
||||
></wa-textarea>
|
||||
<div style="display:flex; gap:8px; margin-top:6px; justify-content:flex-end;">
|
||||
<wa-button size="small" appearance="outlined" @click=${this.cancelEdit}>Cancel</wa-button>
|
||||
<wa-button size="small" variant="brand" @click=${this.saveEdit}>Save</wa-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return html`<div class="tc-comment__body">${this.renderMarkdown(this.comment.body)}</div>`;
|
||||
}
|
||||
|
||||
private renderMarkdown(text: string): ReturnType<typeof html> {
|
||||
return html`<div .innerHTML=${this.sanitize(this.simpleMarkdown(text))}></div>`;
|
||||
}
|
||||
|
||||
private sanitize(html: string): string {
|
||||
return html
|
||||
.replace(/<script\b[^>]*>([\s\S]*?)<\/script>/gim, "")
|
||||
.replace(/on\w+="[^"]*"/gim, "")
|
||||
.replace(/on\w+='[^']*'/gim, "");
|
||||
}
|
||||
|
||||
private simpleMarkdown(text: string): string {
|
||||
return this.escapeHtml(text)
|
||||
.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/\n\n/g, '</p><p>')
|
||||
.replace(/\n/g, '<br>')
|
||||
.replace(/^/, '<p>')
|
||||
.replace(/$/, '</p>');
|
||||
}
|
||||
|
||||
private escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const c = this.comment;
|
||||
const reactions = c.reactions || [];
|
||||
|
||||
return html`
|
||||
<div class="tc-comment">
|
||||
<div class="tc-comment__header">
|
||||
<wa-avatar initials="${c.author.charAt(0).toUpperCase()}" label="${c.author}" style="--size: 26px;"></wa-avatar>
|
||||
<div class="tc-comment__meta">
|
||||
<span class="tc-comment__author">${c.author}</span>
|
||||
<span class="tc-comment__time">${this.formatTime(c.date)}${c.edited_at ? ' (edited)' : ''}</span>
|
||||
</div>
|
||||
${this.isOwn && !this.editing ? html`
|
||||
<div class="tc-comment__menu">
|
||||
<wa-button size="small" appearance="plain" title="Edit" @click=${this.startEdit}>
|
||||
<wa-icon name="pen-to-square" variant="regular"></wa-icon>
|
||||
</wa-button>
|
||||
<wa-button size="small" appearance="plain" title="Delete" @click=${this.requestDelete}>
|
||||
<wa-icon name="trash-can" variant="regular"></wa-icon>
|
||||
</wa-button>
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
${this.renderBody()}
|
||||
|
||||
<div class="tc-comment__footer">
|
||||
${reactions.length > 0 ? html`
|
||||
<div class="tc-reactions">
|
||||
${reactions.map(r => html`
|
||||
<button
|
||||
class="tc-reaction ${r.authors.includes(this.currentAuthor ?? '') ? 'tc-reaction--active' : ''}"
|
||||
title="${r.authors.join(', ')}"
|
||||
@click=${() => this.addReaction(r.emoji)}
|
||||
>${r.emoji} <span class="tc-reaction__count">${r.authors.length}</span></button>
|
||||
`)}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="tc-comment__actions">
|
||||
<wa-button
|
||||
id="emoji-trigger-${c.id}"
|
||||
size="small"
|
||||
appearance="plain"
|
||||
title="Add reaction"
|
||||
@click=${this.toggleEmojiPicker}
|
||||
>
|
||||
<wa-icon name="face-smile" variant="regular"></wa-icon>
|
||||
</wa-button>
|
||||
<wa-popover
|
||||
placement="bottom-start"
|
||||
.anchor=${this.querySelector(`#emoji-trigger-${c.id}`)}
|
||||
?open=${this.showEmojiPicker}
|
||||
@wa-hide=${() => { this.showEmojiPicker = false; }}
|
||||
>
|
||||
<div style="display:flex; gap:2px; padding:4px 6px;">
|
||||
${EMOJI_PRESETS.map(e => html`
|
||||
<button class="tc-emoji-picker__item" @click=${() => this.addReaction(e)}>${e}</button>
|
||||
`)}
|
||||
</div>
|
||||
</wa-popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { t } from '../lib/i18n';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@awesome.me/webawesome/dist/components/textarea/textarea.js';
|
||||
|
||||
@customElement('threads-compose')
|
||||
export class ThreadsCompose extends LitElement {
|
||||
override createRenderRoot() { return this; }
|
||||
|
||||
@property({ type: String }) placeholder = '';
|
||||
@property({ type: String }) quote: string | null = null;
|
||||
@property({ type: String, attribute: 'submit-label' }) submitLabel = '';
|
||||
@property({ type: Boolean }) cancellable = false;
|
||||
|
||||
private submit(): void {
|
||||
const textarea = this.querySelector<HTMLElement & { value: string }>('wa-textarea');
|
||||
const value = textarea?.value?.trim();
|
||||
if (!value) return;
|
||||
this.dispatchEvent(new CustomEvent('compose-submit', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { body: value },
|
||||
}));
|
||||
if (textarea) textarea.value = '';
|
||||
}
|
||||
|
||||
private cancel(): void {
|
||||
this.dispatchEvent(new CustomEvent('compose-cancel', {
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="tc-compose">
|
||||
${this.quote ? html`
|
||||
<div class="tc-compose__quote">${this.quote}</div>
|
||||
` : nothing}
|
||||
<wa-textarea
|
||||
placeholder=${this.placeholder || t('addComment')}
|
||||
rows="2"
|
||||
resize="vertical"
|
||||
size="small"
|
||||
></wa-textarea>
|
||||
<div class="tc-compose__actions">
|
||||
${this.cancellable ? html`
|
||||
<wa-button size="small" appearance="plain" @click=${this.cancel}>${t('cancel')}</wa-button>
|
||||
` : nothing}
|
||||
<wa-button size="small" variant="brand" @click=${this.submit}>${this.submitLabel || t('comment')}</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { LitElement, html } from 'lit';
|
||||
import { customElement, state } from 'lit/decorators.js';
|
||||
import { t } from '../lib/i18n';
|
||||
import {
|
||||
getAuthor, setAuthor,
|
||||
getEmail, setEmail,
|
||||
getGithub, setGithub,
|
||||
getAvatarUrl, setAvatarUrl,
|
||||
computeAvatarUrl,
|
||||
computeAuthorKey, setAuthorKey,
|
||||
} from '../lib/identity';
|
||||
import { upsertAuthor } from '../lib/api';
|
||||
|
||||
@customElement('threads-identity')
|
||||
export class ThreadsIdentity extends LitElement {
|
||||
override createRenderRoot() { return this; }
|
||||
|
||||
@state() private name = '';
|
||||
@state() private email = '';
|
||||
@state() private github = '';
|
||||
@state() private avatarUrl = '';
|
||||
@state() private panelOpen = false;
|
||||
|
||||
private outsideClickHandler = (e: MouseEvent) => {
|
||||
if (!this.contains(e.target as Node)) {
|
||||
this.panelOpen = false;
|
||||
}
|
||||
};
|
||||
|
||||
override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.name = getAuthor() || '';
|
||||
this.email = getEmail() || '';
|
||||
this.github = getGithub() || '';
|
||||
this.avatarUrl = getAvatarUrl() || '';
|
||||
|
||||
// If we have identity info but no avatar yet, compute one
|
||||
if (!this.avatarUrl && (this.email || this.github)) {
|
||||
this.refreshAvatar();
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', this.outsideClickHandler);
|
||||
}
|
||||
|
||||
override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener('mousedown', this.outsideClickHandler);
|
||||
}
|
||||
|
||||
private togglePanel(): void {
|
||||
this.panelOpen = !this.panelOpen;
|
||||
}
|
||||
|
||||
private async handleSave(): Promise<void> {
|
||||
const nameInput = this.querySelector<HTMLInputElement>('#identity-name');
|
||||
const emailInput = this.querySelector<HTMLInputElement>('#identity-email');
|
||||
const githubInput = this.querySelector<HTMLInputElement>('#identity-github');
|
||||
|
||||
if (nameInput) {
|
||||
const val = nameInput.value.trim();
|
||||
this.name = val;
|
||||
if (val) setAuthor(val);
|
||||
}
|
||||
|
||||
if (emailInput) {
|
||||
const val = emailInput.value.trim();
|
||||
this.email = val;
|
||||
setEmail(val);
|
||||
}
|
||||
|
||||
if (githubInput) {
|
||||
const val = githubInput.value.trim().replace(/^@/, '');
|
||||
this.github = val;
|
||||
setGithub(val);
|
||||
}
|
||||
|
||||
await this.refreshAvatar();
|
||||
|
||||
// Compute and save author key
|
||||
const authorKey = computeAuthorKey(this.name, this.github);
|
||||
setAuthorKey(authorKey);
|
||||
|
||||
// Sync to server authors.json
|
||||
try {
|
||||
await upsertAuthor(authorKey, this.name, this.avatarUrl);
|
||||
} catch {
|
||||
// Server may not be available (e.g. static site); ignore
|
||||
}
|
||||
|
||||
this.panelOpen = false;
|
||||
}
|
||||
|
||||
private async refreshAvatar(): Promise<void> {
|
||||
const url = await computeAvatarUrl(this.email, this.github);
|
||||
this.avatarUrl = url;
|
||||
setAvatarUrl(url);
|
||||
}
|
||||
|
||||
private handleCancel(): void {
|
||||
this.panelOpen = false;
|
||||
}
|
||||
|
||||
override render() {
|
||||
const initial = this.name ? this.name.charAt(0).toUpperCase() : '?';
|
||||
|
||||
return html`
|
||||
<button
|
||||
class="threads-identity-btn"
|
||||
title=${t('discussionIdentity')}
|
||||
@click=${this.togglePanel}
|
||||
>
|
||||
${this.avatarUrl
|
||||
? html`<img class="threads-identity-avatar" src=${this.avatarUrl} alt=${this.name} />`
|
||||
: html`<span class="threads-identity-initial">${initial}</span>`
|
||||
}
|
||||
</button>
|
||||
|
||||
${this.panelOpen ? html`
|
||||
<div class="threads-identity-panel">
|
||||
<div class="threads-identity-preview">
|
||||
${this.avatarUrl
|
||||
? html`<img class="threads-identity-preview-img" src=${this.avatarUrl} alt=${this.name} />`
|
||||
: html`<div class="threads-identity-preview-placeholder">${initial}</div>`
|
||||
}
|
||||
</div>
|
||||
<label class="threads-identity-label">
|
||||
${t('displayName')}
|
||||
<input
|
||||
id="identity-name"
|
||||
class="threads-identity-input"
|
||||
type="text"
|
||||
.value=${this.name}
|
||||
placeholder=${t('yourName')}
|
||||
/>
|
||||
</label>
|
||||
<label class="threads-identity-label">
|
||||
${t('githubUsername')}
|
||||
<input
|
||||
id="identity-github"
|
||||
class="threads-identity-input"
|
||||
type="text"
|
||||
.value=${this.github}
|
||||
placeholder="octocat"
|
||||
/>
|
||||
</label>
|
||||
<label class="threads-identity-label">
|
||||
${t('gravatarEmail')}
|
||||
<input
|
||||
id="identity-email"
|
||||
class="threads-identity-input"
|
||||
type="email"
|
||||
.value=${this.email}
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</label>
|
||||
<p class="threads-identity-hint">
|
||||
${t('avatarHint')}
|
||||
</p>
|
||||
<div class="threads-identity-actions">
|
||||
<button class="threads-identity-cancel" @click=${this.handleCancel}>${t('cancel')}</button>
|
||||
<button class="threads-identity-save" @click=${this.handleSave}>${t('save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { t } from '../lib/i18n';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@awesome.me/webawesome/dist/components/textarea/textarea.js';
|
||||
|
||||
@customElement('threads-inline-editor')
|
||||
export class ThreadsInlineEditor extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
margin: 16px 0;
|
||||
}
|
||||
.editor {
|
||||
border: 1px solid var(--tc-border, hsl(0 0% 89.8%));
|
||||
border-radius: var(--tc-radius, 6px);
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
background: var(--tc-card, hsl(0 0% 100%));
|
||||
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
}
|
||||
.editor-body {
|
||||
padding: 12px;
|
||||
}
|
||||
wa-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
.editor-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--tc-border, hsl(0 0% 89.8%));
|
||||
background: var(--wa-color-surface-raised, hsl(0 0% 96.1%));
|
||||
}
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
color: var(--tc-muted-fg, hsl(0 0% 45.1%));
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ type: String }) quote = '';
|
||||
@state() private value = '';
|
||||
@state() private submitting = false;
|
||||
|
||||
private _innerTextarea: HTMLTextAreaElement | null = null;
|
||||
private _nativeInputListener = (e: Event) => {
|
||||
this.value = (e.target as HTMLTextAreaElement).value;
|
||||
};
|
||||
|
||||
private _attachNativeListener(): void {
|
||||
const waTextarea = this.shadowRoot?.querySelector('wa-textarea');
|
||||
if (!waTextarea) return;
|
||||
const inner = (waTextarea as Element).shadowRoot?.querySelector('textarea') as HTMLTextAreaElement | null;
|
||||
if (inner && inner !== this._innerTextarea) {
|
||||
if (this._innerTextarea) {
|
||||
this._innerTextarea.removeEventListener('input', this._nativeInputListener);
|
||||
}
|
||||
this._innerTextarea = inner;
|
||||
inner.addEventListener('input', this._nativeInputListener);
|
||||
}
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
// Attach native input listener and focus after wa-textarea upgrades its internal DOM.
|
||||
requestAnimationFrame(() => {
|
||||
this._attachNativeListener();
|
||||
const inner = this._innerTextarea;
|
||||
if (inner) {
|
||||
inner.focus();
|
||||
} else {
|
||||
const waTextarea = this.shadowRoot?.querySelector('wa-textarea');
|
||||
(waTextarea as unknown as HTMLElement)?.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
override updated() {
|
||||
this._attachNativeListener();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this._innerTextarea) {
|
||||
this._innerTextarea.removeEventListener('input', this._nativeInputListener);
|
||||
this._innerTextarea = null;
|
||||
}
|
||||
}
|
||||
|
||||
private handleInput(e: Event) {
|
||||
this.value = (e.target as any).value ?? '';
|
||||
}
|
||||
|
||||
private getTextareaValue(): string {
|
||||
// Read from native textarea as source of truth (handles Playwright fill())
|
||||
if (this._innerTextarea) return this._innerTextarea.value;
|
||||
const waTextarea = this.shadowRoot?.querySelector('wa-textarea') as any;
|
||||
if (waTextarea?.value !== undefined) return String(waTextarea.value);
|
||||
return this.value;
|
||||
}
|
||||
|
||||
private submit() {
|
||||
const body = this.getTextareaValue().trim();
|
||||
if (!body || this.submitting) return;
|
||||
this.value = body; // sync state
|
||||
this.submitting = true;
|
||||
this.dispatchEvent(new CustomEvent('inline-submit', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { body },
|
||||
}));
|
||||
}
|
||||
|
||||
private cancel() {
|
||||
this.dispatchEvent(new CustomEvent('inline-cancel', {
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<div class="editor">
|
||||
<div class="editor-body">
|
||||
<wa-textarea
|
||||
placeholder=${t('writeComment')}
|
||||
.value=${this.value}
|
||||
rows="3"
|
||||
resize="vertical"
|
||||
size="small"
|
||||
@wa-input=${this.handleInput}
|
||||
@input=${this.handleInput}
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) this.submit();
|
||||
if (e.key === 'Escape') this.cancel();
|
||||
}}
|
||||
></wa-textarea>
|
||||
</div>
|
||||
<div class="editor-footer">
|
||||
<span class="hint">${t('cmdEnterSubmit')}</span>
|
||||
<div class="actions">
|
||||
<wa-button size="small" appearance="outlined" @click=${this.cancel}>${t('cancel')}</wa-button>
|
||||
<wa-button
|
||||
size="small"
|
||||
variant="brand"
|
||||
?disabled=${!this.value.trim() || this.submitting}
|
||||
@click=${this.submit}
|
||||
>${this.submitting ? t('saving') : t('submit')}</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { t } from '../lib/i18n';
|
||||
import type { Thread, Anchor } from '../../types';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@awesome.me/webawesome/dist/components/badge/badge.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
import '@awesome.me/webawesome/dist/components/divider/divider.js';
|
||||
|
||||
import './threads-thread';
|
||||
import './threads-compose';
|
||||
|
||||
type FilterType = 'all' | 'open' | 'resolved';
|
||||
|
||||
@customElement('threads-panel')
|
||||
export class ThreadsPanel extends LitElement {
|
||||
override createRenderRoot() { return this; }
|
||||
|
||||
@property({ type: Boolean }) open = false;
|
||||
@property({ type: Array }) threads: Thread[] = [];
|
||||
@property({ type: String }) filter: FilterType = 'all';
|
||||
@property({ type: String, attribute: 'focused-thread' }) focusedThreadId: string | null = null;
|
||||
@property({ type: Object }) composing: { anchor?: Anchor } | null = null;
|
||||
@property({ type: String }) currentAuthor: string | null = null;
|
||||
@property({ attribute: false }) orphanIds: Set<string> = new Set();
|
||||
@property({ attribute: false }) threadQuotes: Map<string, string> = new Map();
|
||||
|
||||
private get filteredThreads(): Thread[] {
|
||||
return this.threads.filter(t => {
|
||||
if (this.filter === 'open') return !t.resolved;
|
||||
if (this.filter === 'resolved') return t.resolved;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private setFilter(f: FilterType): void {
|
||||
this.filter = f;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
private handleClose(): void {
|
||||
this.dispatchEvent(new CustomEvent('panel-close', {
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleNewThreadSubmit(e: CustomEvent): void {
|
||||
this.dispatchEvent(new CustomEvent('compose-new-thread', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { body: e.detail.body },
|
||||
}));
|
||||
}
|
||||
|
||||
private handleCancelCompose(): void {
|
||||
this.dispatchEvent(new CustomEvent('compose-cancel-new', {
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
private handleAddGeneralComment(): void {
|
||||
this.dispatchEvent(new CustomEvent('compose-general-comment', {
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
override updated(changed: Map<string, unknown>) {
|
||||
if (changed.has('focusedThreadId') && this.focusedThreadId) {
|
||||
setTimeout(() => {
|
||||
const el = this.querySelector(`threads-thread[data-thread-id="${this.focusedThreadId}"]`);
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.open) return nothing;
|
||||
|
||||
const filtered = this.filteredThreads;
|
||||
|
||||
const filterBtn = (f: FilterType, label: string) => html`
|
||||
<wa-button
|
||||
size="small"
|
||||
appearance=${this.filter === f ? 'filled' : 'plain'}
|
||||
variant="neutral"
|
||||
@click=${() => this.setFilter(f)}
|
||||
>${label}</wa-button>
|
||||
`;
|
||||
|
||||
const quote = this.composing?.anchor?.quote ?? '';
|
||||
const truncatedQuote = quote.length > 120
|
||||
? quote.slice(0, 120) + '...'
|
||||
: quote;
|
||||
|
||||
return html`
|
||||
<div class="tc-panel">
|
||||
<div class="tc-panel__header">
|
||||
<div class="tc-panel__title">
|
||||
<span>${t('threads')}</span>
|
||||
<wa-badge variant="neutral" pill>${this.threads.filter(t => !t.resolved).length}</wa-badge>
|
||||
</div>
|
||||
<div class="tc-panel__header-actions">
|
||||
<wa-button size="small" appearance="plain" title=${t('addGeneralComment')} @click=${this.handleAddGeneralComment}>
|
||||
<wa-icon name="plus" variant="solid"></wa-icon>
|
||||
</wa-button>
|
||||
<wa-button size="small" appearance="plain" title=${t('collapseSidebar')} @click=${this.handleClose}>
|
||||
<wa-icon name="angles-right" variant="solid"></wa-icon>
|
||||
</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tc-panel__filters">
|
||||
${filterBtn('all', t('all'))}
|
||||
${filterBtn('open', t('open'))}
|
||||
${filterBtn('resolved', t('resolved'))}
|
||||
</div>
|
||||
|
||||
<div class="tc-panel__body">
|
||||
${this.composing ? html`
|
||||
<div class="tc-new-thread">
|
||||
<threads-compose
|
||||
quote=${truncatedQuote}
|
||||
placeholder="Add a comment..."
|
||||
submit-label="Comment"
|
||||
?cancellable=${true}
|
||||
@compose-submit=${this.handleNewThreadSubmit}
|
||||
@compose-cancel=${this.handleCancelCompose}
|
||||
></threads-compose>
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
${filtered.length === 0 && !this.composing ? html`
|
||||
<div class="tc-empty">
|
||||
<wa-icon name="comments" variant="regular" style="font-size:32px; opacity:0.4; margin-bottom:8px;"></wa-icon>
|
||||
${t('noThreads')}<br>${t('selectTextToStart')}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
${filtered.map(thread => html`
|
||||
<threads-thread
|
||||
data-thread-id=${thread.id}
|
||||
.thread=${thread}
|
||||
.quote=${this.threadQuotes.get(thread.id) ?? null}
|
||||
?focused=${this.focusedThreadId === thread.id}
|
||||
?orphan=${this.orphanIds.has(thread.id)}
|
||||
.currentAuthor=${this.currentAuthor}
|
||||
></threads-thread>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
|
||||
@customElement('threads-popover')
|
||||
export class ThreadsPopover extends LitElement {
|
||||
static override styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
z-index: 10001;
|
||||
}
|
||||
.popover-content {
|
||||
background: var(--tc-bg, hsl(0 0% 100%));
|
||||
border: 1px solid var(--tc-border, hsl(0 0% 89.8%));
|
||||
border-radius: var(--tc-radius, 6px);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
padding: 4px;
|
||||
}
|
||||
wa-button::part(base) {
|
||||
color: var(--tc-fg, hsl(0 0% 9%));
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
wa-button::part(base):hover {
|
||||
background: var(--tc-accent, hsl(0 0% 96.1%));
|
||||
}
|
||||
`;
|
||||
|
||||
@property({ type: Boolean }) active = false;
|
||||
@property({ type: Number }) x = 0;
|
||||
@property({ type: Number }) y = 0;
|
||||
|
||||
private addComment(): void {
|
||||
this.dispatchEvent(new CustomEvent('add-comment', {
|
||||
bubbles: true, composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (!this.active) return html``;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="popover-content"
|
||||
style="position:fixed; left:${this.x}px; top:${this.y - 10}px; transform:translate(-50%, -100%);"
|
||||
>
|
||||
<wa-button size="small" appearance="plain" @click=${this.addComment}>
|
||||
<wa-icon slot="start" name="comment" variant="regular" label="Add comment"></wa-icon>
|
||||
Add comment
|
||||
</wa-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
show(x: number, y: number): void {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.active = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { LitElement, html, nothing } from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import type { Thread } from '../../types';
|
||||
|
||||
import '@awesome.me/webawesome/dist/components/button/button.js';
|
||||
import '@awesome.me/webawesome/dist/components/divider/divider.js';
|
||||
import '@awesome.me/webawesome/dist/components/tag/tag.js';
|
||||
|
||||
import './threads-comment';
|
||||
import './threads-compose';
|
||||
|
||||
@customElement('threads-thread')
|
||||
export class ThreadsThread extends LitElement {
|
||||
override createRenderRoot() { return this; }
|
||||
|
||||
@property({ type: Object }) thread!: Thread;
|
||||
@property({ type: Boolean }) focused = false;
|
||||
@property({ type: Boolean }) orphan = false;
|
||||
@property({ type: String }) currentAuthor: string | null = null;
|
||||
@property({ type: String }) quote: string | null = null;
|
||||
|
||||
private scrollToHighlight(): void {
|
||||
this.dispatchEvent(new CustomEvent('thread-scroll-to', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { threadId: this.thread.id },
|
||||
}));
|
||||
}
|
||||
|
||||
private toggleResolve(): void {
|
||||
this.dispatchEvent(new CustomEvent('thread-resolve', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { threadId: this.thread.id },
|
||||
}));
|
||||
}
|
||||
|
||||
private requestDelete(): void {
|
||||
this.dispatchEvent(new CustomEvent('thread-delete', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { threadId: this.thread.id },
|
||||
}));
|
||||
}
|
||||
|
||||
private handleReply(e: CustomEvent): void {
|
||||
this.dispatchEvent(new CustomEvent('thread-reply', {
|
||||
bubbles: true, composed: true,
|
||||
detail: { threadId: this.thread.id, body: e.detail.body },
|
||||
}));
|
||||
}
|
||||
|
||||
override render() {
|
||||
const t = this.thread;
|
||||
const truncatedQuote = this.quote
|
||||
? (this.quote.length > 100 ? this.quote.slice(0, 100) + '...' : this.quote)
|
||||
: null;
|
||||
|
||||
return html`
|
||||
<div class="tc-thread ${this.focused ? 'tc-thread--focused' : ''} ${t.resolved ? 'tc-thread--resolved' : ''}">
|
||||
${truncatedQuote ? html`
|
||||
<div class="tc-thread__quote" @click=${this.scrollToHighlight}>
|
||||
<div class="tc-thread__quote-text">${truncatedQuote}</div>
|
||||
${this.orphan ? html`<wa-tag size="small" variant="warning" pill>orphaned</wa-tag>` : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
|
||||
<div class="tc-thread__comments">
|
||||
${t.comments.map(c => html`
|
||||
<threads-comment
|
||||
.comment=${c}
|
||||
.currentAuthor=${this.currentAuthor}
|
||||
></threads-comment>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="tc-thread__reply">
|
||||
<threads-compose
|
||||
placeholder="Reply to this thread..."
|
||||
submit-label="Reply"
|
||||
@compose-submit=${this.handleReply}
|
||||
></threads-compose>
|
||||
</div>
|
||||
|
||||
<div class="tc-thread__footer">
|
||||
<wa-button size="small" appearance="plain" variant=${t.resolved ? 'neutral' : 'success'} @click=${this.toggleResolve}>
|
||||
${t.resolved ? 'Unresolve' : 'Resolve'}
|
||||
</wa-button>
|
||||
<wa-button size="small" appearance="plain" variant="danger" @click=${this.requestDelete}>Delete</wa-button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||
import './components/threads-app';
|
||||
|
||||
function init(): void {
|
||||
if (document.querySelector('threads-app')) return;
|
||||
const app = document.createElement('threads-app');
|
||||
document.body.appendChild(app);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Thread, Comment, Reaction, AuthorsMap } from '../../types';
|
||||
|
||||
declare global {
|
||||
var docmd: {
|
||||
call(action: string, payload: any): Promise<any>;
|
||||
send(name: string, data: any): void;
|
||||
on(name: string, callback: (data: any) => void): () => void;
|
||||
afterReload(name: string, callback: (ctx: any) => void): void;
|
||||
scheduleReload(name: string, context?: any): void;
|
||||
};
|
||||
}
|
||||
|
||||
function getSourceFile(): string {
|
||||
const file = document.body.dataset['sourceFile'];
|
||||
if (!file) throw new Error('[threads] data-source-file not found on body element');
|
||||
return file;
|
||||
}
|
||||
|
||||
export async function fetchAuthors(): Promise<AuthorsMap> {
|
||||
try {
|
||||
return await docmd.call('threads:get-authors', {});
|
||||
} catch {
|
||||
// Fallback to injected global (static builds)
|
||||
return (window as any).__threads_authors || {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function upsertAuthor(authorKey: string, name: string, avatarUrl: string): Promise<void> {
|
||||
await docmd.call('threads:upsert-author', { authorKey, name, avatarUrl });
|
||||
}
|
||||
|
||||
export async function fetchThreads(): Promise<Thread[]> {
|
||||
return docmd.call('threads:get-threads', { file: getSourceFile() });
|
||||
}
|
||||
|
||||
export async function createThread(payload: {
|
||||
anchor: any | null;
|
||||
author: string;
|
||||
body: string;
|
||||
authorKey?: string;
|
||||
avatarUrl?: string;
|
||||
}): Promise<Thread> {
|
||||
return docmd.call('threads:add-thread', {
|
||||
file: getSourceFile(),
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function addComment(
|
||||
threadId: string,
|
||||
payload: { author: string; body: string; parentId?: string | null; authorKey?: string; avatarUrl?: string },
|
||||
): Promise<Comment> {
|
||||
return docmd.call('threads:add-comment', {
|
||||
file: getSourceFile(),
|
||||
threadId,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function editComment(
|
||||
threadId: string,
|
||||
commentId: string,
|
||||
payload: { body: string },
|
||||
): Promise<Comment> {
|
||||
return docmd.call('threads:edit-comment', {
|
||||
file: getSourceFile(),
|
||||
threadId,
|
||||
commentId,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteComment(
|
||||
threadId: string,
|
||||
commentId: string,
|
||||
): Promise<void> {
|
||||
await docmd.call('threads:delete-comment', {
|
||||
file: getSourceFile(),
|
||||
threadId,
|
||||
commentId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteThread(threadId: string): Promise<void> {
|
||||
await docmd.call('threads:delete-thread', {
|
||||
file: getSourceFile(),
|
||||
threadId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveThread(
|
||||
threadId: string,
|
||||
payload: { resolved_by: string },
|
||||
): Promise<Thread> {
|
||||
return docmd.call('threads:resolve-thread', {
|
||||
file: getSourceFile(),
|
||||
threadId,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
|
||||
export async function toggleReaction(
|
||||
threadId: string,
|
||||
commentId: string,
|
||||
payload: { emoji: string; author: string },
|
||||
): Promise<Reaction[]> {
|
||||
return docmd.call('threads:toggle-reaction', {
|
||||
file: getSourceFile(),
|
||||
threadId,
|
||||
commentId,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import type { Thread, Anchor } from '../../types';
|
||||
import { getContentArea } from './selection';
|
||||
|
||||
interface AnchorResult {
|
||||
threadId: string;
|
||||
range: Range | null;
|
||||
orphan: boolean;
|
||||
}
|
||||
|
||||
function findTextInNode(
|
||||
root: Node,
|
||||
searchText: string,
|
||||
): Range | null {
|
||||
const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
|
||||
let accumulated = "";
|
||||
const textNodes: { node: Text; start: number }[] = [];
|
||||
|
||||
while (treeWalker.nextNode()) {
|
||||
const node = treeWalker.currentNode as Text;
|
||||
textNodes.push({ node, start: accumulated.length });
|
||||
accumulated += node.textContent || "";
|
||||
}
|
||||
|
||||
const idx = accumulated.indexOf(searchText);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const endIdx = idx + searchText.length;
|
||||
let startNode: Text | null = null;
|
||||
let startOffset = 0;
|
||||
let endNode: Text | null = null;
|
||||
let endOffset = 0;
|
||||
|
||||
for (const { node, start } of textNodes) {
|
||||
const nodeEnd = start + (node.textContent?.length || 0);
|
||||
if (!startNode && idx >= start && idx < nodeEnd) {
|
||||
startNode = node;
|
||||
startOffset = idx - start;
|
||||
}
|
||||
if (endIdx > start && endIdx <= nodeEnd) {
|
||||
endNode = node;
|
||||
endOffset = endIdx - start;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!startNode || !endNode) return null;
|
||||
|
||||
const range = document.createRange();
|
||||
range.setStart(startNode, startOffset);
|
||||
range.setEnd(endNode, endOffset);
|
||||
return range;
|
||||
}
|
||||
|
||||
function tryExactMatch(anchor: Anchor): Range | null {
|
||||
if (!anchor.selector || anchor.offset === null) return null;
|
||||
|
||||
const content = getContentArea();
|
||||
if (!content) return null;
|
||||
|
||||
const element = content.querySelector(anchor.selector);
|
||||
if (!element) return null;
|
||||
|
||||
const text = element.textContent || "";
|
||||
const slice = text.slice(anchor.offset, anchor.offset + anchor.quote.length);
|
||||
if (slice !== anchor.quote) return null;
|
||||
|
||||
return findTextInNode(element, anchor.quote);
|
||||
}
|
||||
|
||||
function tryElementSearch(anchor: Anchor): Range | null {
|
||||
if (!anchor.selector) return null;
|
||||
|
||||
const content = getContentArea();
|
||||
if (!content) return null;
|
||||
|
||||
const element = content.querySelector(anchor.selector);
|
||||
if (!element) return null;
|
||||
|
||||
return findTextInNode(element, anchor.quote);
|
||||
}
|
||||
|
||||
function tryPageSearch(anchor: Anchor): Range | null {
|
||||
const content = getContentArea();
|
||||
if (!content) return null;
|
||||
|
||||
const fullText = content.textContent || "";
|
||||
const indices: number[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while ((pos = fullText.indexOf(anchor.quote, pos)) !== -1) {
|
||||
indices.push(pos);
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
if (indices.length === 0) return null;
|
||||
|
||||
// If single match, use it
|
||||
if (indices.length === 1) {
|
||||
return findTextInNode(content, anchor.quote);
|
||||
}
|
||||
|
||||
// Disambiguate with prefix/suffix
|
||||
if (anchor.prefix || anchor.suffix) {
|
||||
for (const idx of indices) {
|
||||
const before = fullText.slice(Math.max(0, idx - 40), idx);
|
||||
const after = fullText.slice(
|
||||
idx + anchor.quote.length,
|
||||
idx + anchor.quote.length + 40,
|
||||
);
|
||||
|
||||
const prefixMatch = !anchor.prefix || before.endsWith(anchor.prefix);
|
||||
const suffixMatch = !anchor.suffix || after.startsWith(anchor.suffix);
|
||||
|
||||
if (prefixMatch || suffixMatch) {
|
||||
return findTextInNode(content, anchor.quote);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use first match
|
||||
return findTextInNode(content, anchor.quote);
|
||||
}
|
||||
|
||||
export function reanchor(thread: Thread): AnchorResult {
|
||||
const anchor = thread.anchor;
|
||||
|
||||
// Untethered comments have no anchor - skip re-anchoring
|
||||
if (!anchor || !anchor.quote) {
|
||||
return { threadId: thread.id, range: null, orphan: false };
|
||||
}
|
||||
|
||||
// Try strategies in order of confidence
|
||||
const range =
|
||||
tryExactMatch(anchor) ||
|
||||
tryElementSearch(anchor) ||
|
||||
tryPageSearch(anchor);
|
||||
|
||||
return {
|
||||
threadId: thread.id,
|
||||
range,
|
||||
orphan: range === null,
|
||||
};
|
||||
}
|
||||
|
||||
const highlightMap = new Map<string, HTMLElement[]>();
|
||||
|
||||
export function clearHighlights(): void {
|
||||
for (const [, marks] of highlightMap) {
|
||||
for (const mark of marks) {
|
||||
const parent = mark.parentNode;
|
||||
if (parent) {
|
||||
while (mark.firstChild) {
|
||||
parent.insertBefore(mark.firstChild, mark);
|
||||
}
|
||||
parent.removeChild(mark);
|
||||
parent.normalize();
|
||||
}
|
||||
}
|
||||
}
|
||||
highlightMap.clear();
|
||||
}
|
||||
|
||||
export function applyHighlight(
|
||||
threadId: string,
|
||||
range: Range,
|
||||
resolved: boolean,
|
||||
onClick: (threadId: string) => void,
|
||||
): void {
|
||||
const marks: HTMLElement[] = [];
|
||||
|
||||
// Handle ranges that span multiple text nodes
|
||||
const contents = range.cloneContents();
|
||||
const textNodes: Text[] = [];
|
||||
const walker = document.createTreeWalker(contents, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
textNodes.push(walker.currentNode as Text);
|
||||
}
|
||||
|
||||
// Use surroundContents for simple single-node ranges
|
||||
if (
|
||||
range.startContainer === range.endContainer &&
|
||||
range.startContainer.nodeType === Node.TEXT_NODE
|
||||
) {
|
||||
const mark = document.createElement("mark");
|
||||
mark.dataset["threadId"] = threadId;
|
||||
mark.className = `threads-highlight${resolved ? " threads-highlight--resolved" : ""}`;
|
||||
mark.addEventListener("click", () => onClick(threadId));
|
||||
range.surroundContents(mark);
|
||||
marks.push(mark);
|
||||
} else {
|
||||
// For multi-node ranges, highlight each text node individually
|
||||
const treeWalker = document.createTreeWalker(
|
||||
range.commonAncestorContainer,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
const nodesToWrap: { node: Text; start: number; end: number }[] = [];
|
||||
|
||||
while (treeWalker.nextNode()) {
|
||||
const textNode = treeWalker.currentNode as Text;
|
||||
if (!range.intersectsNode(textNode)) continue;
|
||||
|
||||
let start = 0;
|
||||
let end = textNode.length;
|
||||
|
||||
if (textNode === range.startContainer) start = range.startOffset;
|
||||
if (textNode === range.endContainer) end = range.endOffset;
|
||||
|
||||
if (start < end) {
|
||||
nodesToWrap.push({ node: textNode, start, end });
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap in reverse order to preserve offsets
|
||||
for (let i = nodesToWrap.length - 1; i >= 0; i--) {
|
||||
const entry = nodesToWrap[i];
|
||||
if (!entry) continue;
|
||||
const { node, start, end } = entry;
|
||||
const subRange = document.createRange();
|
||||
subRange.setStart(node, start);
|
||||
subRange.setEnd(node, end);
|
||||
|
||||
const mark = document.createElement("mark");
|
||||
mark.dataset["threadId"] = threadId;
|
||||
mark.className = `threads-highlight${resolved ? " threads-highlight--resolved" : ""}`;
|
||||
mark.addEventListener("click", () => onClick(threadId));
|
||||
subRange.surroundContents(mark);
|
||||
marks.push(mark);
|
||||
}
|
||||
}
|
||||
|
||||
highlightMap.set(threadId, marks);
|
||||
}
|
||||
|
||||
export function scrollToHighlight(threadId: string): void {
|
||||
const marks = highlightMap.get(threadId);
|
||||
if (marks && marks[0]) {
|
||||
marks[0].scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
// Flash effect
|
||||
for (const mark of marks) {
|
||||
mark.classList.add("threads-highlight--flash");
|
||||
setTimeout(() => mark.classList.remove("threads-highlight--flash"), 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Client-side i18n helper for the threads plugin.
|
||||
* Reads translations from window.__threads_i18n injected by the server plugin.
|
||||
* Falls back to English defaults if a key is missing.
|
||||
*/
|
||||
|
||||
const defaults: Record<string, string> = {
|
||||
threads: 'Threads',
|
||||
newThread: 'New Thread',
|
||||
newComment: 'New Comment',
|
||||
startThread: 'Start a new discussion thread',
|
||||
addGeneralComment: 'Add general comment',
|
||||
collapseSidebar: 'Collapse sidebar',
|
||||
openThreadsPanel: 'Open threads panel',
|
||||
closePanel: 'Close panel',
|
||||
jumpToThreads: 'Jump to threads',
|
||||
all: 'All',
|
||||
open: 'Open',
|
||||
resolved: 'Resolved',
|
||||
noThreads: 'No threads on this page yet.',
|
||||
selectTextToStart: 'Select text to start one.',
|
||||
writeComment: 'Write your comment...',
|
||||
addComment: 'Add a comment...',
|
||||
cmdEnterSubmit: 'Cmd+Enter to submit',
|
||||
cancel: 'Cancel',
|
||||
submit: 'Submit',
|
||||
saving: 'Saving...',
|
||||
comment: 'Comment',
|
||||
reply: 'Reply',
|
||||
replyToComment: 'Reply to this comment',
|
||||
deleteComment: 'Delete comment',
|
||||
collapseThread: 'Collapse thread',
|
||||
expandThread: 'Expand thread',
|
||||
confirmDelete: 'Confirm Delete',
|
||||
confirmDeleteMessage: 'Are you sure you want to delete this',
|
||||
delete: 'Delete',
|
||||
discussionIdentity: 'Discussion identity',
|
||||
displayName: 'Display Name',
|
||||
yourName: 'Your name',
|
||||
githubUsername: 'GitHub Username',
|
||||
gravatarEmail: 'Gravatar Email',
|
||||
save: 'Save',
|
||||
avatarHint: 'Avatar: Gravatar > GitHub > random. Stored in your browser only.',
|
||||
openCount: 'open',
|
||||
commentCount: 'comment',
|
||||
commentsCount: 'comments',
|
||||
by: 'by',
|
||||
and: 'and',
|
||||
more: 'more',
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a translated string by key.
|
||||
* Priority: window.__threads_i18n[key] → defaults[key] → key itself.
|
||||
*/
|
||||
export function t(key: string): string {
|
||||
const injected = (window as any).__threads_i18n;
|
||||
if (injected && typeof injected[key] === 'string') return injected[key];
|
||||
return defaults[key] || key;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
const STORAGE_KEY_NAME = 'threads_author';
|
||||
const STORAGE_KEY_EMAIL = 'threads_email';
|
||||
const STORAGE_KEY_GITHUB = 'threads_github';
|
||||
const STORAGE_KEY_AVATAR = 'threads_avatar_url';
|
||||
const STORAGE_KEY_AUTHOR_KEY = 'threads_author_key';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__docmd_dev?: {
|
||||
name: string;
|
||||
email: string;
|
||||
gravatarUrl: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthor(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEY_NAME);
|
||||
}
|
||||
|
||||
export function setAuthor(name: string): void {
|
||||
localStorage.setItem(STORAGE_KEY_NAME, name);
|
||||
}
|
||||
|
||||
export function getEmail(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEY_EMAIL);
|
||||
}
|
||||
|
||||
export function setEmail(email: string): void {
|
||||
localStorage.setItem(STORAGE_KEY_EMAIL, email);
|
||||
}
|
||||
|
||||
export function getGithub(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEY_GITHUB);
|
||||
}
|
||||
|
||||
export function setGithub(username: string): void {
|
||||
localStorage.setItem(STORAGE_KEY_GITHUB, username);
|
||||
}
|
||||
|
||||
export function getAvatarUrl(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEY_AVATAR);
|
||||
}
|
||||
|
||||
export function setAvatarUrl(url: string): void {
|
||||
localStorage.setItem(STORAGE_KEY_AVATAR, url);
|
||||
}
|
||||
|
||||
export function getAuthorKey(): string | null {
|
||||
return localStorage.getItem(STORAGE_KEY_AUTHOR_KEY);
|
||||
}
|
||||
|
||||
export function setAuthorKey(key: string): void {
|
||||
localStorage.setItem(STORAGE_KEY_AUTHOR_KEY, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the author key from available identity info.
|
||||
* Priority: GitHub username > slugified name + short hash.
|
||||
*/
|
||||
export function computeAuthorKey(name: string, github: string): string {
|
||||
if (github) return github.toLowerCase();
|
||||
if (name) {
|
||||
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
return slug || 'anonymous';
|
||||
}
|
||||
return 'anonymous';
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the best avatar URL given available info.
|
||||
* Priority: Gravatar (if email) > GitHub > DiceBear seeded random.
|
||||
*
|
||||
* For Gravatar we use d=404 so we can detect missing avatars and fall back.
|
||||
* This function tries Gravatar first; if it 404s, falls back to GitHub, then DiceBear.
|
||||
*/
|
||||
export async function computeAvatarUrl(email: string, github: string): Promise<string> {
|
||||
// 1. Try Gravatar if email is provided
|
||||
if (email) {
|
||||
const gravatarUrl = await getGravatarUrl(email);
|
||||
if (gravatarUrl) return gravatarUrl;
|
||||
}
|
||||
|
||||
// 2. Try GitHub avatar
|
||||
if (github) {
|
||||
return `https://github.com/${encodeURIComponent(github)}.png?size=80`;
|
||||
}
|
||||
|
||||
// 3. DiceBear seeded avatar
|
||||
const seed = email || github || Math.random().toString(36).slice(2);
|
||||
return `https://api.dicebear.com/9.x/thumbs/svg?seed=${encodeURIComponent(seed)}&size=80`;
|
||||
}
|
||||
|
||||
async function getGravatarUrl(email: string): Promise<string | null> {
|
||||
try {
|
||||
const encoder = new TextEncoder();
|
||||
const data = encoder.encode(email.toLowerCase().trim());
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
const url = `https://gravatar.com/avatar/${hashHex}?s=80&d=404`;
|
||||
|
||||
// Check if Gravatar actually has an image
|
||||
const _resp = await fetch(url, { method: 'HEAD', mode: 'no-cors' });
|
||||
// no-cors means we can't read status, so just return the displayable URL
|
||||
// Use d=blank to detect, but for display use d=404 won't work visually.
|
||||
// Instead, just return with d=mp fallback and let it show the default.
|
||||
return `https://gravatar.com/avatar/${hashHex}?s=80&d=mp`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On first load, seed identity from git config if available
|
||||
* and nothing is stored yet.
|
||||
*/
|
||||
export function initIdentity(): void {
|
||||
const devInfo = window.__docmd_dev;
|
||||
if (!devInfo) return;
|
||||
|
||||
if (!getAuthor() && devInfo.name) {
|
||||
setAuthor(devInfo.name);
|
||||
}
|
||||
if (!getEmail() && devInfo.email) {
|
||||
setEmail(devInfo.email);
|
||||
}
|
||||
if (!getAvatarUrl() && devInfo.gravatarUrl) {
|
||||
setAvatarUrl(devInfo.gravatarUrl);
|
||||
}
|
||||
}
|
||||
|
||||
export function ensureAuthor(): string {
|
||||
let author = getAuthor();
|
||||
if (!author) {
|
||||
author = prompt('Enter your display name for discussions:');
|
||||
if (!author || !author.trim()) {
|
||||
author = 'Anonymous';
|
||||
}
|
||||
setAuthor(author.trim());
|
||||
}
|
||||
|
||||
// Ensure author key exists
|
||||
if (!getAuthorKey()) {
|
||||
const github = getGithub() || '';
|
||||
setAuthorKey(computeAuthorKey(author, github));
|
||||
}
|
||||
|
||||
return author;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full identity info needed for creating comments.
|
||||
*/
|
||||
export function getIdentityPayload(): { author: string; authorKey: string; avatarUrl: string } {
|
||||
const author = ensureAuthor();
|
||||
const authorKey = getAuthorKey() || computeAuthorKey(author, getGithub() || '');
|
||||
const avatarUrl = getAvatarUrl() || '';
|
||||
return { author, authorKey, avatarUrl };
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import type { Anchor } from '../../types';
|
||||
|
||||
const CONTEXT_CHARS = 40;
|
||||
const BLOCK_ELEMENTS = new Set([
|
||||
"P", "DIV", "LI", "TD", "TH", "BLOCKQUOTE", "PRE", "H1", "H2", "H3",
|
||||
"H4", "H5", "H6", "SECTION", "ARTICLE", "ASIDE", "DT", "DD", "FIGCAPTION",
|
||||
]);
|
||||
|
||||
export function getContentArea(): HTMLElement | null {
|
||||
// docmd content area selectors (try multiple)
|
||||
return (
|
||||
document.querySelector<HTMLElement>("[data-docmd-content]") ||
|
||||
document.querySelector<HTMLElement>(".docmd-content") ||
|
||||
document.querySelector<HTMLElement>("article") ||
|
||||
document.querySelector<HTMLElement>("main")
|
||||
);
|
||||
}
|
||||
|
||||
export function isWithinContent(node: Node): boolean {
|
||||
const content = getContentArea();
|
||||
return content ? content.contains(node) : false;
|
||||
}
|
||||
|
||||
function getBlockAncestor(node: Node): HTMLElement {
|
||||
let current: Node | null = node;
|
||||
while (current && current !== document.body) {
|
||||
if (
|
||||
current instanceof HTMLElement &&
|
||||
BLOCK_ELEMENTS.has(current.tagName)
|
||||
) {
|
||||
return current;
|
||||
}
|
||||
current = current.parentNode;
|
||||
}
|
||||
return document.body;
|
||||
}
|
||||
|
||||
function generateSelector(element: HTMLElement): string {
|
||||
const parts: string[] = [];
|
||||
const content = getContentArea();
|
||||
|
||||
for (let cur = element as HTMLElement | null; cur && cur !== document.body && cur !== content; cur = cur.parentElement) {
|
||||
const tag = cur.tagName.toLowerCase();
|
||||
const parent = cur.parentElement;
|
||||
if (parent) {
|
||||
const tagName = cur.tagName;
|
||||
const siblings = Array.from(parent.children).filter(
|
||||
(sibling) => sibling.tagName === tagName,
|
||||
);
|
||||
if (siblings.length > 1) {
|
||||
const index = siblings.indexOf(cur) + 1;
|
||||
parts.unshift(`${tag}:nth-of-type(${index})`);
|
||||
} else {
|
||||
parts.unshift(tag);
|
||||
}
|
||||
} else {
|
||||
parts.unshift(tag);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(" > ");
|
||||
}
|
||||
|
||||
function getTextOffset(container: HTMLElement, range: Range): number {
|
||||
const treeWalker = document.createTreeWalker(
|
||||
container,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
);
|
||||
let offset = 0;
|
||||
|
||||
while (treeWalker.nextNode()) {
|
||||
if (treeWalker.currentNode === range.startContainer) {
|
||||
return offset + range.startOffset;
|
||||
}
|
||||
offset += (treeWalker.currentNode as Text).length;
|
||||
}
|
||||
|
||||
return offset;
|
||||
}
|
||||
|
||||
function extractContext(
|
||||
text: string,
|
||||
start: number,
|
||||
end: number,
|
||||
): { prefix: string; suffix: string } {
|
||||
const prefix = text.slice(Math.max(0, start - CONTEXT_CHARS), start);
|
||||
const suffix = text.slice(end, end + CONTEXT_CHARS);
|
||||
return { prefix, suffix };
|
||||
}
|
||||
|
||||
export function computeAnchor(selection: Selection): Anchor | null {
|
||||
if (selection.rangeCount === 0) return null;
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const quote = selection.toString().trim();
|
||||
|
||||
if (!quote || quote.length < 3) return null;
|
||||
if (!isWithinContent(range.startContainer)) return null;
|
||||
|
||||
const blockEl = getBlockAncestor(range.startContainer);
|
||||
const selector = generateSelector(blockEl);
|
||||
const fullText = blockEl.textContent || "";
|
||||
const offset = getTextOffset(blockEl, range);
|
||||
const { prefix, suffix } = extractContext(fullText, offset, offset + quote.length);
|
||||
|
||||
return {
|
||||
quote,
|
||||
prefix: prefix || null,
|
||||
suffix: suffix || null,
|
||||
selector,
|
||||
offset,
|
||||
blockText: fullText.trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSelectionPosition(selection: Selection): { x: number; y: number } | null {
|
||||
if (selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
return {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { injectComponentStyles } from '../components/styles';
|
||||
|
||||
const THEME_STYLE_ID = 'threads-theme-bridge';
|
||||
|
||||
export function initThemeBridge(): void {
|
||||
injectThemeCSS();
|
||||
syncDarkMode();
|
||||
observeThemeChanges();
|
||||
injectComponentStyles();
|
||||
}
|
||||
|
||||
function injectThemeCSS(): void {
|
||||
if (document.getElementById(THEME_STYLE_ID)) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = THEME_STYLE_ID;
|
||||
style.textContent = `
|
||||
:root {
|
||||
--wa-color-surface-default: var(--tc-bg, hsl(0 0% 100%));
|
||||
--wa-color-surface-raised: var(--tc-muted, hsl(0 0% 96.1%));
|
||||
--wa-color-surface-border: var(--tc-border, hsl(0 0% 89.8%));
|
||||
--wa-color-text-normal: var(--tc-fg, hsl(0 0% 9%));
|
||||
--wa-color-text-quiet: var(--tc-muted-fg, hsl(0 0% 45.1%));
|
||||
--wa-color-text-link: var(--tc-fg, hsl(0 0% 9%));
|
||||
--wa-color-brand-fill-loud: var(--tc-fg, hsl(0 0% 9%));
|
||||
--wa-color-brand-on-loud: hsl(0 0% 98%);
|
||||
--wa-color-focus: var(--tc-ring, hsl(0 0% 9%));
|
||||
--wa-font-sans: var(--tc-font, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif);
|
||||
--wa-font-mono: var(--font-family-mono, SFMono-Regular, Consolas, Menlo, monospace);
|
||||
}
|
||||
|
||||
.threads-highlight {
|
||||
background-color: hsl(48 96% 89% / 0.5);
|
||||
border-bottom: 2px solid hsl(48 96% 53% / 0.6);
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.threads-highlight:hover {
|
||||
background-color: hsl(48 96% 89% / 0.8);
|
||||
}
|
||||
.threads-highlight--resolved {
|
||||
background-color: hsl(142 76% 36% / 0.1);
|
||||
border-bottom-color: hsl(142 76% 36% / 0.3);
|
||||
}
|
||||
.threads-highlight--resolved:hover {
|
||||
background-color: hsl(142 76% 36% / 0.2);
|
||||
}
|
||||
.threads-highlight--flash {
|
||||
animation: threads-flash 0.8s ease-out;
|
||||
}
|
||||
@keyframes threads-flash {
|
||||
0%, 40% { background-color: hsl(48 96% 53% / 0.5); }
|
||||
100% { background-color: hsl(48 96% 89% / 0.5); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function syncDarkMode(): void {
|
||||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
document.documentElement.classList.toggle('wa-dark', isDark);
|
||||
document.documentElement.classList.toggle('wa-light', !isDark);
|
||||
}
|
||||
|
||||
function observeThemeChanges(): void {
|
||||
const observer = new MutationObserver(() => syncDarkMode());
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme'],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"strict": true,
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"skipLibCheck": true,
|
||||
"moduleDetection": "force"
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { setup as setupContainers } from './plugin/containers.js';
|
||||
import { setup as setupHighlightRule } from './plugin/highlight-rule.js';
|
||||
import { actions } from './plugin/actions.js';
|
||||
import { scriptLiteral } from '@docmd/utils';
|
||||
import type { PluginDescriptor } from '@docmd/api';
|
||||
|
||||
export const plugin: PluginDescriptor = {
|
||||
name: 'threads',
|
||||
version: '0.8.12',
|
||||
capabilities: ['markdown', 'body', 'assets', 'actions', 'translations']
|
||||
};
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const i18nDir = path.resolve(__dirname, '..', 'i18n');
|
||||
|
||||
function loadPluginStrings(localeId: string): Record<string, string> {
|
||||
try {
|
||||
const localePath = path.join(i18nDir, `${localeId}.json`);
|
||||
if (fs.existsSync(localePath)) {
|
||||
return JSON.parse(fs.readFileSync(localePath, 'utf8'));
|
||||
}
|
||||
} catch { /* fallback below */ }
|
||||
try {
|
||||
const enPath = path.join(i18nDir, 'en.json');
|
||||
if (fs.existsSync(enPath)) {
|
||||
return JSON.parse(fs.readFileSync(enPath, 'utf8'));
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
return {};
|
||||
}
|
||||
|
||||
export function translations(localeId: string): Record<string, string> {
|
||||
return loadPluginStrings(localeId || 'en');
|
||||
}
|
||||
|
||||
export function markdownSetup(md: any, _options?: any): void {
|
||||
setupContainers(md);
|
||||
setupHighlightRule(md);
|
||||
}
|
||||
|
||||
export function generateScripts(config: any, options?: any): { headScriptsHtml: string; bodyScriptsHtml: string } {
|
||||
// S-7: parse authors.json, never inline the raw file text. scriptLiteral
|
||||
// escapes </script, <!--, U+2028, U+2029 so the JSON is safe inside <script>.
|
||||
let authors: unknown = {};
|
||||
try {
|
||||
const srcDir = config.src || 'docs';
|
||||
const authorsPath = path.resolve(srcDir, '.threads', 'authors.json');
|
||||
if (fs.existsSync(authorsPath)) {
|
||||
const parsed = JSON.parse(fs.readFileSync(authorsPath, 'utf8'));
|
||||
// Runtime schema is a plain object — coerce arrays/primitives to {}.
|
||||
authors = (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(`[threads] Failed to parse .threads/authors.json: ${err.message}. Using empty authors.`);
|
||||
authors = {};
|
||||
}
|
||||
|
||||
const clientConfig = { sidebar: options?.sidebar === true };
|
||||
const i18nStrings = loadPluginStrings(config._activeLocale?.id || 'en');
|
||||
|
||||
return {
|
||||
headScriptsHtml: '',
|
||||
bodyScriptsHtml: `<script>window.__threads_authors=${scriptLiteral(authors)};window.__threads_config=${scriptLiteral(clientConfig)};window.__threads_i18n=${scriptLiteral(i18nStrings)}</script>`
|
||||
};
|
||||
}
|
||||
|
||||
export function getAssets(_options?: any): any[] {
|
||||
// Resolve relative to the compiled output location
|
||||
const distDir = path.resolve(__dirname, '..', 'dist', 'client');
|
||||
return [
|
||||
{
|
||||
src: path.join(distDir, 'index.js'),
|
||||
dest: 'assets/js/threads.js',
|
||||
type: 'js',
|
||||
location: 'body',
|
||||
attributes: { type: 'module' }
|
||||
},
|
||||
{
|
||||
src: path.join(distDir, 'index.css'),
|
||||
dest: 'assets/css/threads.css',
|
||||
type: 'css',
|
||||
location: 'head'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
export { actions };
|
||||
@@ -0,0 +1,381 @@
|
||||
import crypto from 'crypto';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import * as parser from './parser.js';
|
||||
import type { Thread, Comment, Reaction } from '../types.js';
|
||||
|
||||
// TODO(0.7.0): Import ActionContext from @docmd/api once types package is extracted
|
||||
interface ActionContext {
|
||||
projectRoot: string;
|
||||
config: any;
|
||||
readFile(relativePath: string): Promise<string>;
|
||||
writeFile(relativePath: string, content: string): Promise<void>;
|
||||
readFileLines(relativePath: string): Promise<string[]>;
|
||||
broadcast(event: string, data: any): void;
|
||||
}
|
||||
|
||||
interface AuthorsMap {
|
||||
[key: string]: { name: string; avatarUrl: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the .threads directory inside the docs source folder.
|
||||
* Falls back to projectRoot if config.src is not available.
|
||||
*/
|
||||
function getThreadsDir(ctx: ActionContext): string {
|
||||
const srcDir = ctx.config?.src || '';
|
||||
return path.join(ctx.projectRoot, srcDir, '.threads');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the authors map from <docsRoot>/.threads/authors.json.
|
||||
* Returns {} if file doesn't exist.
|
||||
*/
|
||||
async function readAuthors(ctx: ActionContext): Promise<AuthorsMap> {
|
||||
const filePath = path.join(getThreadsDir(ctx), 'authors.json');
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, 'utf8');
|
||||
return JSON.parse(content) as AuthorsMap;
|
||||
} catch (e: any) {
|
||||
if (e.code === 'ENOENT') return {};
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the authors map to <docsRoot>/.threads/authors.json.
|
||||
* Creates the directory if needed.
|
||||
*/
|
||||
async function writeAuthors(ctx: ActionContext, authors: AuthorsMap): Promise<void> {
|
||||
const dirPath = getThreadsDir(ctx);
|
||||
const filePath = path.join(dirPath, 'authors.json');
|
||||
await fs.promises.mkdir(dirPath, { recursive: true });
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(authors, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Upsert an author entry. Updates name/avatarUrl if provided.
|
||||
*/
|
||||
async function upsertAuthor(ctx: ActionContext, authorKey: string, name: string, avatarUrl?: string): Promise<void> {
|
||||
const authors = await readAuthors(ctx);
|
||||
const existing = authors[authorKey] || { name: '', avatarUrl: '' };
|
||||
authors[authorKey] = {
|
||||
name: name || existing.name || authorKey,
|
||||
avatarUrl: avatarUrl || existing.avatarUrl || '',
|
||||
};
|
||||
await writeAuthors(ctx, authors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a thread ID: "t-" + 8 random hex chars.
|
||||
*/
|
||||
function generateThreadId(): string {
|
||||
return 't-' + crypto.randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a comment ID: "c-" + 8 random hex chars.
|
||||
*/
|
||||
function generateCommentId(): string {
|
||||
return 'c-' + crypto.randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date in YYYY-MM-DD format.
|
||||
*/
|
||||
function todayDate(): string {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a file's content and parse its threads.
|
||||
*/
|
||||
async function readAndParse(file: string, ctx: ActionContext): Promise<{ content: string; threads: Thread[] }> {
|
||||
const content = await ctx.readFile(file);
|
||||
const threads = parser.parseThreadsFromContent(content);
|
||||
return { content, threads };
|
||||
}
|
||||
|
||||
/**
|
||||
* Write threads back to a file, replacing the existing threads block.
|
||||
*/
|
||||
async function writeBack(file: string, content: string, threads: Thread[], ctx: ActionContext): Promise<void> {
|
||||
const updated = parser.replaceThreadsBlock(content, threads);
|
||||
await ctx.writeFile(file, updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a thread by ID, throwing if not found.
|
||||
*/
|
||||
function findThread(threads: Thread[], threadId: string): Thread {
|
||||
const thread = threads.find((t) => t.id === threadId);
|
||||
if (!thread) throw new Error(`Thread not found: ${threadId}`);
|
||||
return thread;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a comment by ID within a thread, throwing if not found.
|
||||
*/
|
||||
function findComment(thread: Thread, commentId: string): Comment {
|
||||
const comment = thread.comments.find((c) => c.id === commentId);
|
||||
if (!comment) throw new Error(`Comment not found: ${commentId}`);
|
||||
return comment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required string fields are present and non-empty.
|
||||
*/
|
||||
function requireFields(action: string, payload: Record<string, any>, fields: string[]): void {
|
||||
for (const field of fields) {
|
||||
if (!payload[field] || (typeof payload[field] === 'string' && !payload[field].trim())) {
|
||||
throw new Error(`${action}: ${field} is required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const actions: Record<string, (payload: any, ctx: ActionContext) => Promise<any>> = {
|
||||
/**
|
||||
* Get the authors map. Read-only, no reload.
|
||||
*/
|
||||
'threads:get-authors': async (payload: any, ctx: ActionContext): Promise<AuthorsMap> => {
|
||||
return readAuthors(ctx);
|
||||
},
|
||||
|
||||
/**
|
||||
* Upsert an author entry directly.
|
||||
*/
|
||||
'threads:upsert-author': async (payload: any, ctx: ActionContext): Promise<{ ok: boolean }> => {
|
||||
requireFields('threads:upsert-author', payload, ['authorKey', 'name']);
|
||||
await upsertAuthor(ctx, payload.authorKey, payload.name, payload.avatarUrl);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Get all threads from a file. Read-only, no reload.
|
||||
*/
|
||||
'threads:get-threads': async (payload: any, ctx: ActionContext): Promise<Thread[]> => {
|
||||
requireFields('threads:get-threads', payload, ['file']);
|
||||
const content = await ctx.readFile(payload.file);
|
||||
return parser.parseThreadsFromContent(content);
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a new thread with its first comment.
|
||||
*/
|
||||
'threads:add-thread': async (payload: any, ctx: ActionContext): Promise<Thread> => {
|
||||
requireFields('threads:add-thread', payload, ['file', 'author', 'body']);
|
||||
const { file, author, body, anchor, authorKey, avatarUrl } = payload;
|
||||
|
||||
// Upsert author profile if key provided
|
||||
if (authorKey) {
|
||||
await upsertAuthor(ctx, authorKey, author, avatarUrl);
|
||||
}
|
||||
const { content, threads } = await readAndParse(file, ctx);
|
||||
|
||||
const threadId = generateThreadId();
|
||||
const commentId = generateCommentId();
|
||||
const date = todayDate();
|
||||
|
||||
const thread: Thread = {
|
||||
id: threadId,
|
||||
resolved: false,
|
||||
resolved_by: null,
|
||||
resolved_at: null,
|
||||
comments: [
|
||||
{
|
||||
id: commentId,
|
||||
thread_id: threadId,
|
||||
parent_id: null,
|
||||
author,
|
||||
date,
|
||||
edited_at: null,
|
||||
body,
|
||||
reactions: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
threads.push(thread);
|
||||
|
||||
// If anchor has a quote, wrap it with highlight markup in the document body
|
||||
let contentForWrite = content;
|
||||
if (anchor && anchor.quote) {
|
||||
const quote = anchor.quote as string;
|
||||
// Split at ::: threads boundary to avoid matching inside the threads block
|
||||
const threadsIdx = content.indexOf('\n::: threads');
|
||||
let bodyContent: string;
|
||||
let rest: string;
|
||||
if (threadsIdx >= 0) {
|
||||
bodyContent = content.slice(0, threadsIdx);
|
||||
rest = content.slice(threadsIdx);
|
||||
} else {
|
||||
bodyContent = content;
|
||||
rest = '';
|
||||
}
|
||||
|
||||
const quoteIdx = bodyContent.indexOf(quote);
|
||||
if (quoteIdx >= 0) {
|
||||
bodyContent =
|
||||
bodyContent.slice(0, quoteIdx) +
|
||||
`==${quote}=={${threadId}}` +
|
||||
bodyContent.slice(quoteIdx + quote.length);
|
||||
}
|
||||
|
||||
contentForWrite = bodyContent + rest;
|
||||
}
|
||||
|
||||
const updated = parser.replaceThreadsBlock(contentForWrite, threads);
|
||||
await ctx.writeFile(file, updated);
|
||||
return thread;
|
||||
},
|
||||
|
||||
/**
|
||||
* Add a comment to an existing thread.
|
||||
*/
|
||||
'threads:add-comment': async (payload: any, ctx: ActionContext): Promise<Comment> => {
|
||||
requireFields('threads:add-comment', payload, ['file', 'threadId', 'author', 'body']);
|
||||
const { file, threadId, author, body, parentId, authorKey, avatarUrl } = payload;
|
||||
|
||||
// Upsert author profile if key provided
|
||||
if (authorKey) {
|
||||
await upsertAuthor(ctx, authorKey, author, avatarUrl);
|
||||
}
|
||||
const { content, threads } = await readAndParse(file, ctx);
|
||||
const thread = findThread(threads, threadId);
|
||||
|
||||
const commentId = generateCommentId();
|
||||
const date = todayDate();
|
||||
|
||||
const comment: Comment = {
|
||||
id: commentId,
|
||||
thread_id: threadId,
|
||||
parent_id: parentId || null,
|
||||
author,
|
||||
date,
|
||||
edited_at: null,
|
||||
body,
|
||||
reactions: [],
|
||||
};
|
||||
|
||||
thread.comments.push(comment);
|
||||
await writeBack(file, content, threads, ctx);
|
||||
return comment;
|
||||
},
|
||||
|
||||
/**
|
||||
* Edit an existing comment's body.
|
||||
*/
|
||||
'threads:edit-comment': async (payload: any, ctx: ActionContext): Promise<Comment> => {
|
||||
requireFields('threads:edit-comment', payload, ['file', 'threadId', 'commentId', 'body']);
|
||||
const { file, threadId, commentId, body } = payload;
|
||||
const { content, threads } = await readAndParse(file, ctx);
|
||||
const thread = findThread(threads, threadId);
|
||||
const comment = findComment(thread, commentId);
|
||||
|
||||
comment.body = body;
|
||||
comment.edited_at = todayDate();
|
||||
|
||||
await writeBack(file, content, threads, ctx);
|
||||
return comment;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete a comment from a thread.
|
||||
*/
|
||||
'threads:delete-comment': async (payload: any, ctx: ActionContext): Promise<{ deleted: boolean }> => {
|
||||
requireFields('threads:delete-comment', payload, ['file', 'threadId', 'commentId']);
|
||||
const { file, threadId, commentId } = payload;
|
||||
const { content, threads } = await readAndParse(file, ctx);
|
||||
const thread = findThread(threads, threadId);
|
||||
|
||||
const idx = thread.comments.findIndex((c) => c.id === commentId);
|
||||
if (idx === -1) throw new Error(`Comment not found: ${commentId}`);
|
||||
thread.comments.splice(idx, 1);
|
||||
|
||||
await writeBack(file, content, threads, ctx);
|
||||
return { deleted: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an entire thread.
|
||||
*/
|
||||
'threads:delete-thread': async (payload: any, ctx: ActionContext): Promise<{ deleted: boolean }> => {
|
||||
requireFields('threads:delete-thread', payload, ['file', 'threadId']);
|
||||
const { file, threadId } = payload;
|
||||
const parsed = await readAndParse(file, ctx);
|
||||
const { threads } = parsed;
|
||||
let { content } = parsed;
|
||||
|
||||
const idx = threads.findIndex((t) => t.id === threadId);
|
||||
if (idx === -1) throw new Error(`Thread not found: ${threadId}`);
|
||||
threads.splice(idx, 1);
|
||||
|
||||
// Remove orphaned ==highlight=={threadId} markup from the body,
|
||||
// keeping just the inner text
|
||||
const highlightRe = new RegExp(`==((?:(?!==).)+)==\\{${threadId}\\}`, 'g');
|
||||
content = content.replace(highlightRe, '$1');
|
||||
|
||||
await writeBack(file, content, threads, ctx);
|
||||
return { deleted: true };
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle resolved status on a thread.
|
||||
*/
|
||||
'threads:resolve-thread': async (payload: any, ctx: ActionContext): Promise<Thread> => {
|
||||
requireFields('threads:resolve-thread', payload, ['file', 'threadId', 'resolved_by']);
|
||||
const { file, threadId, resolved_by } = payload;
|
||||
const { content, threads } = await readAndParse(file, ctx);
|
||||
const thread = findThread(threads, threadId);
|
||||
|
||||
if (thread.resolved) {
|
||||
thread.resolved = false;
|
||||
thread.resolved_by = null;
|
||||
thread.resolved_at = null;
|
||||
} else {
|
||||
thread.resolved = true;
|
||||
thread.resolved_by = resolved_by;
|
||||
thread.resolved_at = todayDate();
|
||||
}
|
||||
|
||||
await writeBack(file, content, threads, ctx);
|
||||
return thread;
|
||||
},
|
||||
|
||||
/**
|
||||
* Toggle a reaction emoji on a comment.
|
||||
*/
|
||||
'threads:toggle-reaction': async (payload: any, ctx: ActionContext): Promise<Reaction[]> => {
|
||||
requireFields('threads:toggle-reaction', payload, ['file', 'threadId', 'commentId', 'emoji', 'author']);
|
||||
const { file, threadId, commentId, emoji, author } = payload;
|
||||
const { content, threads } = await readAndParse(file, ctx);
|
||||
const thread = findThread(threads, threadId);
|
||||
const comment = findComment(thread, commentId);
|
||||
|
||||
if (!comment.reactions) comment.reactions = [];
|
||||
|
||||
const reaction = comment.reactions.find((r) => r.emoji === emoji);
|
||||
|
||||
if (reaction) {
|
||||
const authorIdx = reaction.authors.indexOf(author);
|
||||
if (authorIdx >= 0) {
|
||||
// Remove author from this reaction
|
||||
reaction.authors.splice(authorIdx, 1);
|
||||
// If no authors left, remove the reaction entirely
|
||||
if (reaction.authors.length === 0) {
|
||||
const reactionIdx = comment.reactions.indexOf(reaction);
|
||||
comment.reactions.splice(reactionIdx, 1);
|
||||
}
|
||||
} else {
|
||||
// Add author to existing reaction
|
||||
reaction.authors.push(author);
|
||||
}
|
||||
} else {
|
||||
// Create new reaction
|
||||
comment.reactions.push({ emoji, authors: [author] });
|
||||
}
|
||||
|
||||
await writeBack(file, content, threads, ctx);
|
||||
return comment.reactions;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,263 @@
|
||||
// Import createDepthTrackingContainer from the parser package
|
||||
import { createDepthTrackingContainer } from '@docmd/parser';
|
||||
|
||||
interface ThreadInfo {
|
||||
id: string;
|
||||
resolved: boolean;
|
||||
resolvedBy: string | null;
|
||||
resolvedAt: string | null;
|
||||
}
|
||||
|
||||
interface CommentInfo {
|
||||
id: string | null;
|
||||
parentId: string | null;
|
||||
author: string;
|
||||
date: string;
|
||||
editedAt: string | null;
|
||||
}
|
||||
|
||||
function smartDedent(str: string): string {
|
||||
const lines = str.split('\n');
|
||||
|
||||
while (lines.length && lines[0].trim() === '') lines.shift();
|
||||
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
||||
|
||||
let minIndent = Infinity;
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const indent = line.match(/^ */)?.[0].length || 0;
|
||||
minIndent = Math.min(minIndent, indent);
|
||||
}
|
||||
|
||||
if (!isFinite(minIndent) || minIndent === 0) return lines.join('\n');
|
||||
|
||||
return lines.map(line =>
|
||||
line.startsWith(' '.repeat(minIndent)) ? line.slice(minIndent) : line
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
function renderMarkdownWithoutRawHtml(md: any, source: string, env: any): string {
|
||||
const previousHtml = md.options.html;
|
||||
md.options.html = false;
|
||||
try {
|
||||
return md.render(source, env);
|
||||
} finally {
|
||||
md.options.html = previousHtml;
|
||||
}
|
||||
}
|
||||
|
||||
function setupSafeCommentContainer(md: any): void {
|
||||
md.block.ruler.before('fence', 'custom_comment', (state: any, startLine: number, endLine: number, silent: boolean) => {
|
||||
const start = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
const lineContent = state.src.slice(start, max).trim();
|
||||
const match = lineContent.match(/^:::\s*comment(?:\s+(.*))?$/);
|
||||
if (!match) return false;
|
||||
if (silent) return true;
|
||||
|
||||
let nextLine = startLine;
|
||||
let found = false;
|
||||
let depth = 1;
|
||||
let fenceMarker: string | null = null;
|
||||
|
||||
while (nextLine < endLine) {
|
||||
nextLine++;
|
||||
if (nextLine >= endLine) break;
|
||||
|
||||
const nextStart = state.bMarks[nextLine] + state.tShift[nextLine];
|
||||
const nextMax = state.eMarks[nextLine];
|
||||
const nextContent = state.src.slice(nextStart, nextMax).trim();
|
||||
|
||||
if (!fenceMarker) {
|
||||
const fenceMatch = nextContent.match(/^(`{3,}|~{3,})/);
|
||||
if (fenceMatch) fenceMarker = fenceMatch[1];
|
||||
} else if (nextContent.startsWith(fenceMarker)) {
|
||||
fenceMarker = null;
|
||||
}
|
||||
|
||||
if (!fenceMarker) {
|
||||
if (nextContent.match(/^:::\s*[a-zA-Z]/) && !nextContent.match(/^:::\s*(button|embed|tag)\b/)) {
|
||||
depth++;
|
||||
} else if (nextContent.match(/^:::\s*$/)) {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) return false;
|
||||
|
||||
let rawContent = '';
|
||||
for (let i = startLine + 1; i < nextLine; i++) {
|
||||
rawContent += state.src.slice(state.bMarks[i], state.eMarks[i]) + '\n';
|
||||
}
|
||||
const innerContent = smartDedent(rawContent);
|
||||
|
||||
const openToken = state.push('custom_comment_open', 'div', 1);
|
||||
openToken.info = match[1] || '';
|
||||
|
||||
if (innerContent) {
|
||||
const oldIsInsideContainer = state.env.isInsideContainer;
|
||||
state.env.isInsideContainer = true;
|
||||
let renderedContent = '';
|
||||
try {
|
||||
renderedContent = renderMarkdownWithoutRawHtml(state.md, innerContent, state.env);
|
||||
} finally {
|
||||
state.env.isInsideContainer = oldIsInsideContainer;
|
||||
}
|
||||
|
||||
const htmlToken = state.push('html_block', '', 0);
|
||||
htmlToken.content = renderedContent;
|
||||
}
|
||||
|
||||
state.push('custom_comment_close', 'div', -1);
|
||||
state.line = nextLine + 1;
|
||||
return true;
|
||||
}, { alt: ['paragraph', 'reference', 'blockquote', 'list'] });
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a thread info string.
|
||||
* Format: `<id> [resolved "<by>" "<date>"]`
|
||||
*/
|
||||
export function parseThreadInfo(info: string): ThreadInfo {
|
||||
const trimmed = info.trim();
|
||||
const resolvedMatch = trimmed.match(
|
||||
/^(\S+)\s+resolved\s+"([^"]+)"\s+"([^"]+)"$/
|
||||
);
|
||||
if (resolvedMatch) {
|
||||
return {
|
||||
id: resolvedMatch[1],
|
||||
resolved: true,
|
||||
resolvedBy: resolvedMatch[2],
|
||||
resolvedAt: resolvedMatch[3],
|
||||
};
|
||||
}
|
||||
|
||||
const simpleMatch = trimmed.match(/^(\S+)$/);
|
||||
if (simpleMatch) {
|
||||
return {
|
||||
id: simpleMatch[1],
|
||||
resolved: false,
|
||||
resolvedBy: null,
|
||||
resolvedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { id: 'unknown', resolved: false, resolvedBy: null, resolvedAt: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comment info string.
|
||||
* Format: `[<id>] "<author>" "<date>" [reply-to <parentId>] [edited "<date>"]`
|
||||
*/
|
||||
export function parseCommentInfo(info: string): CommentInfo {
|
||||
const trimmed = info.trim();
|
||||
|
||||
// Format with ID, optional reply-to, optional edited
|
||||
const fullMatch = trimmed.match(
|
||||
/^(\S+)\s+"([^"]+)"\s+"([^"]+)"(?:\s+reply-to\s+(\S+))?(?:\s+edited\s+"([^"]+)")?$/
|
||||
);
|
||||
if (fullMatch) {
|
||||
return {
|
||||
id: fullMatch[1],
|
||||
author: fullMatch[2],
|
||||
date: fullMatch[3],
|
||||
parentId: fullMatch[4] || null,
|
||||
editedAt: fullMatch[5] || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy format without ID: "<author>" "<date>" edited "<date>"
|
||||
const editedMatch = trimmed.match(
|
||||
/^"([^"]+)"\s+"([^"]+)"\s+edited\s+"([^"]+)"$/
|
||||
);
|
||||
if (editedMatch) {
|
||||
return {
|
||||
id: null,
|
||||
parentId: null,
|
||||
author: editedMatch[1],
|
||||
date: editedMatch[2],
|
||||
editedAt: editedMatch[3],
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy format without ID: "<author>" "<date>"
|
||||
const simpleMatch = trimmed.match(/^"([^"]+)"\s+"([^"]+)"$/);
|
||||
if (simpleMatch) {
|
||||
return {
|
||||
id: null,
|
||||
parentId: null,
|
||||
author: simpleMatch[1],
|
||||
date: simpleMatch[2],
|
||||
editedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
return { id: null, parentId: null, author: 'unknown', date: '', editedAt: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all thread-related container rules on a markdown-it instance.
|
||||
*/
|
||||
export function setup(md: any): void {
|
||||
// 1. threads - outer wrapper
|
||||
createDepthTrackingContainer(
|
||||
md,
|
||||
'threads',
|
||||
() => '<div class="threads-sidebar">\n',
|
||||
() => '</div>\n'
|
||||
);
|
||||
|
||||
// 2. thread - individual thread
|
||||
createDepthTrackingContainer(
|
||||
md,
|
||||
'thread',
|
||||
(tokens: any[], idx: number) => {
|
||||
const info = tokens[idx].info.trim();
|
||||
const parsed = parseThreadInfo(info);
|
||||
const resolvedClass = parsed.resolved ? ' threads-thread--resolved' : '';
|
||||
const safeId = md.utils.escapeHtml(parsed.id);
|
||||
return `<div class="threads-thread${resolvedClass}" data-thread-id="${safeId}">\n`;
|
||||
},
|
||||
() => '</div>\n'
|
||||
);
|
||||
|
||||
// 3. comment - individual comment
|
||||
setupSafeCommentContainer(md);
|
||||
md.renderer.rules.custom_comment_open = (tokens: any[], idx: number) => {
|
||||
const info = tokens[idx].info.trim();
|
||||
const parsed = parseCommentInfo(info);
|
||||
|
||||
const safeId = parsed.id ? md.utils.escapeHtml(parsed.id) : null;
|
||||
const safeParentId = parsed.parentId ? md.utils.escapeHtml(parsed.parentId) : null;
|
||||
const safeAuthor = md.utils.escapeHtml(parsed.author);
|
||||
const safeDate = md.utils.escapeHtml(parsed.date);
|
||||
const safeEdited = parsed.editedAt ? md.utils.escapeHtml(parsed.editedAt) : null;
|
||||
|
||||
const idAttr = safeId ? ` data-comment-id="${safeId}"` : '';
|
||||
const parentAttr = safeParentId ? ` data-parent-id="${safeParentId}"` : '';
|
||||
const editedAttr = safeEdited ? ` data-edited="${safeEdited}"` : '';
|
||||
const replyClass = parsed.parentId ? ' threads-comment--reply' : '';
|
||||
|
||||
return (
|
||||
`<div class="threads-comment${replyClass}"${idAttr}${parentAttr} data-author="${safeAuthor}" data-date="${safeDate}"${editedAttr}>` +
|
||||
`<div class="threads-comment__avatar-col"></div>` +
|
||||
`<div class="threads-comment__meta"><strong>${safeAuthor}</strong> · ${safeDate}</div>` +
|
||||
`<div class="threads-comment__body">\n`
|
||||
);
|
||||
};
|
||||
md.renderer.rules.custom_comment_close = () => '</div></div>\n';
|
||||
|
||||
// 4. reactions - reactions container
|
||||
createDepthTrackingContainer(
|
||||
md,
|
||||
'reactions',
|
||||
() => '<div class="threads-reactions">\n',
|
||||
() => '</div>\n'
|
||||
);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
const MARKER_CHAR = 0x3D; // '='
|
||||
|
||||
/**
|
||||
* Register the highlight_thread inline rule with a markdown-it instance.
|
||||
*/
|
||||
export function setup(md: any): void {
|
||||
md.inline.ruler.push('highlight_thread', highlightThreadRule);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline rule for ==text=={thread-id} highlight syntax.
|
||||
*/
|
||||
function highlightThreadRule(state: any, silent: boolean): boolean {
|
||||
const src: string = state.src;
|
||||
const pos: number = state.pos;
|
||||
const max: number = state.posMax;
|
||||
|
||||
// Need at least ==x== (5 chars)
|
||||
if (pos + 4 > max) return false;
|
||||
|
||||
// Must start with ==
|
||||
if (src.charCodeAt(pos) !== MARKER_CHAR || src.charCodeAt(pos + 1) !== MARKER_CHAR) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Content must not start immediately with another = (avoid ===)
|
||||
if (src.charCodeAt(pos + 2) === MARKER_CHAR) return false;
|
||||
|
||||
// Scan for closing ==
|
||||
let closePos = pos + 2;
|
||||
while (closePos < max - 1) {
|
||||
if (src.charCodeAt(closePos) === MARKER_CHAR && src.charCodeAt(closePos + 1) === MARKER_CHAR) {
|
||||
break;
|
||||
}
|
||||
// No newlines allowed
|
||||
if (src.charCodeAt(closePos) === 0x0A) return false;
|
||||
closePos++;
|
||||
}
|
||||
|
||||
// Did we find closing ==?
|
||||
if (closePos >= max - 1) return false;
|
||||
if (src.charCodeAt(closePos) !== MARKER_CHAR || src.charCodeAt(closePos + 1) !== MARKER_CHAR) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const content = src.slice(pos + 2, closePos);
|
||||
|
||||
// Content must not be empty
|
||||
if (content.length === 0) return false;
|
||||
|
||||
if (silent) return true;
|
||||
|
||||
// Check for optional {thread-id} after closing ==
|
||||
let threadId: string | null = null;
|
||||
let endPos = closePos + 2;
|
||||
|
||||
if (endPos < max && src.charCodeAt(endPos) === 0x7B /* { */) {
|
||||
const braceClose = src.indexOf('}', endPos + 1);
|
||||
if (braceClose !== -1) {
|
||||
threadId = src.slice(endPos + 1, braceClose);
|
||||
endPos = braceClose + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Create tokens
|
||||
const openToken = state.push('mark_open', 'mark', 1);
|
||||
openToken.attrSet('class', 'threads-highlight');
|
||||
if (threadId) {
|
||||
openToken.attrSet('data-thread-id', threadId);
|
||||
}
|
||||
openToken.markup = '==';
|
||||
|
||||
// Parse inner content as inline markup so nested syntax (bold, italic, etc.) works.
|
||||
// We tokenize into the current token stream by saving/restoring state.pos/posMax.
|
||||
const oldPos = state.pos;
|
||||
const oldMax = state.posMax;
|
||||
state.pos = pos + 2;
|
||||
state.posMax = closePos;
|
||||
state.md.inline.tokenize(state);
|
||||
state.pos = oldPos;
|
||||
state.posMax = oldMax;
|
||||
|
||||
const closeToken = state.push('mark_close', 'mark', -1);
|
||||
closeToken.markup = '==';
|
||||
|
||||
state.pos = endPos;
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import crypto from 'crypto';
|
||||
import type { Thread, Comment, Reaction } from '../types.js';
|
||||
|
||||
/**
|
||||
* Generate a comment ID: "c-" + 8 random hex chars.
|
||||
*/
|
||||
function generateCommentId(): string {
|
||||
return 'c-' + crypto.randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the start and end line indices of the top-level `::: threads` block.
|
||||
* Returns null if no block found.
|
||||
*/
|
||||
export function findThreadsBlockBounds(lines: string[]): { start: number; end: number } | null {
|
||||
let start = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '::: threads') {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (start === -1) return null;
|
||||
|
||||
// Track depth to find matching close
|
||||
let depth = 1;
|
||||
for (let i = start + 1; i < lines.length; i++) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith('::: ') || (trimmed === ':::' + '')) {
|
||||
// Distinguish opening vs closing
|
||||
if (trimmed === ':::') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
return { start, end: i };
|
||||
}
|
||||
} else {
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unclosed block - treat rest of file as the block
|
||||
return { start, end: lines.length - 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a container block (lines between opening ::: and closing :::).
|
||||
* Returns array of child containers, each with { infoString, contentLines, startLine }.
|
||||
*/
|
||||
export function parseChildContainers(lines: string[]): Array<{ infoString: string; contentLines: string[]; startLine: number }> {
|
||||
const children: Array<{ infoString: string; contentLines: string[]; startLine: number }> = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const trimmed = lines[i].trim();
|
||||
if (trimmed.startsWith('::: ')) {
|
||||
const infoString = trimmed.slice(4); // everything after "::: "
|
||||
const startLine = i;
|
||||
let depth = 1;
|
||||
const contentLines: string[] = [];
|
||||
i++;
|
||||
|
||||
while (i < lines.length && depth > 0) {
|
||||
const t = lines[i].trim();
|
||||
if (t === ':::') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
contentLines.push(lines[i]);
|
||||
} else if (t.startsWith('::: ')) {
|
||||
depth++;
|
||||
contentLines.push(lines[i]);
|
||||
} else {
|
||||
contentLines.push(lines[i]);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
children.push({ infoString, contentLines, startLine });
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the info string of a thread container.
|
||||
* Format: `thread <id> [resolved "<by>" "<date>"]`
|
||||
*/
|
||||
export function parseThreadInfo(info: string): { id: string; resolved: boolean; resolved_by: string | null; resolved_at: string | null } | null {
|
||||
const resolvedMatch = info.match(
|
||||
/^thread\s+(\S+)\s+resolved\s+"([^"]+)"\s+"([^"]+)"$/
|
||||
);
|
||||
if (resolvedMatch) {
|
||||
return {
|
||||
id: resolvedMatch[1],
|
||||
resolved: true,
|
||||
resolved_by: resolvedMatch[2],
|
||||
resolved_at: resolvedMatch[3],
|
||||
};
|
||||
}
|
||||
|
||||
const simpleMatch = info.match(/^thread\s+(\S+)$/);
|
||||
if (simpleMatch) {
|
||||
return {
|
||||
id: simpleMatch[1],
|
||||
resolved: false,
|
||||
resolved_by: null,
|
||||
resolved_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the info string of a comment container.
|
||||
* Format: `comment <id> "<author>" "<date>" [reply-to <parentId>] [edited "<date>"]`
|
||||
* Legacy format (no id): `comment "<author>" "<date>" [edited "<date>"]`
|
||||
*/
|
||||
export function parseCommentInfo(info: string): { id: string | null; parent_id: string | null; author: string; date: string; edited_at: string | null } | null {
|
||||
// Format with ID, optional reply-to, optional edited
|
||||
// comment <id> "<author>" "<date>" [reply-to <parentId>] [edited "<date>"]
|
||||
const fullMatch = info.match(
|
||||
/^comment\s+(\S+)\s+"([^"]+)"\s+"([^"]+)"(?:\s+reply-to\s+(\S+))?(?:\s+edited\s+"([^"]+)")?$/
|
||||
);
|
||||
if (fullMatch) {
|
||||
return {
|
||||
id: fullMatch[1],
|
||||
author: fullMatch[2],
|
||||
date: fullMatch[3],
|
||||
parent_id: fullMatch[4] || null,
|
||||
edited_at: fullMatch[5] || null,
|
||||
};
|
||||
}
|
||||
|
||||
// Legacy format without ID: comment "<author>" "<date>" edited "<date>"
|
||||
const editedMatch = info.match(
|
||||
/^comment\s+"([^"]+)"\s+"([^"]+)"\s+edited\s+"([^"]+)"$/
|
||||
);
|
||||
if (editedMatch) {
|
||||
return {
|
||||
id: null,
|
||||
parent_id: null,
|
||||
author: editedMatch[1],
|
||||
date: editedMatch[2],
|
||||
edited_at: editedMatch[3],
|
||||
};
|
||||
}
|
||||
|
||||
const simpleMatch = info.match(/^comment\s+"([^"]+)"\s+"([^"]+)"$/);
|
||||
if (simpleMatch) {
|
||||
return {
|
||||
id: null,
|
||||
parent_id: null,
|
||||
author: simpleMatch[1],
|
||||
date: simpleMatch[2],
|
||||
edited_at: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse reactions from content lines inside a ::: reactions block.
|
||||
* Each line: `- <emoji> <author1>, <author2>, ...`
|
||||
*/
|
||||
export function parseReactions(lines: string[]): Reaction[] {
|
||||
const reactions: Reaction[] = [];
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
const match = trimmed.match(/^-\s+(\S+)\s+(.+)$/);
|
||||
if (match) {
|
||||
const emoji = match[1];
|
||||
const authors = match[2].split(',').map((a) => a.trim()).filter(Boolean);
|
||||
reactions.push({ emoji, authors });
|
||||
}
|
||||
}
|
||||
return reactions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip indentation common to all non-empty lines, then trim leading/trailing blank lines.
|
||||
*/
|
||||
function extractBody(lines: string[]): string {
|
||||
// Find minimum indentation of non-empty lines
|
||||
let minIndent = Infinity;
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
const indentMatch = line.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[1].length : 0;
|
||||
if (indent < minIndent) minIndent = indent;
|
||||
}
|
||||
if (minIndent === Infinity) minIndent = 0;
|
||||
|
||||
const stripped = lines.map((l) => (l.trim() === '' ? '' : l.slice(minIndent)));
|
||||
|
||||
// Trim leading and trailing empty lines
|
||||
while (stripped.length > 0 && stripped[0].trim() === '') stripped.shift();
|
||||
while (stripped.length > 0 && stripped[stripped.length - 1].trim() === '') stripped.pop();
|
||||
|
||||
return stripped.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a comment container's content lines into body and reactions.
|
||||
*/
|
||||
export function parseCommentContent(contentLines: string[]): { body: string; reactions: Reaction[] } {
|
||||
// Find if there's a ::: reactions block
|
||||
let reactionsStart = -1;
|
||||
let reactionsEnd = -1;
|
||||
|
||||
for (let i = 0; i < contentLines.length; i++) {
|
||||
if (contentLines[i].trim() === '::: reactions') {
|
||||
reactionsStart = i;
|
||||
let depth = 1;
|
||||
for (let j = i + 1; j < contentLines.length; j++) {
|
||||
const t = contentLines[j].trim();
|
||||
if (t === ':::') {
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
reactionsEnd = j;
|
||||
break;
|
||||
}
|
||||
} else if (t.startsWith('::: ')) {
|
||||
depth++;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let reactions: Reaction[] = [];
|
||||
let bodyLines: string[];
|
||||
|
||||
if (reactionsStart >= 0 && reactionsEnd >= 0) {
|
||||
reactions = parseReactions(contentLines.slice(reactionsStart + 1, reactionsEnd));
|
||||
// Body is everything except the reactions block
|
||||
bodyLines = [
|
||||
...contentLines.slice(0, reactionsStart),
|
||||
...contentLines.slice(reactionsEnd + 1),
|
||||
];
|
||||
} else {
|
||||
bodyLines = contentLines;
|
||||
}
|
||||
|
||||
const body = extractBody(bodyLines);
|
||||
return { body, reactions };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse threads from markdown content.
|
||||
*/
|
||||
export function parseThreadsFromContent(markdownContent: string): Thread[] {
|
||||
const lines = markdownContent.split('\n');
|
||||
const bounds = findThreadsBlockBounds(lines);
|
||||
if (!bounds) return [];
|
||||
|
||||
const innerLines = lines.slice(bounds.start + 1, bounds.end);
|
||||
const threadContainers = parseChildContainers(innerLines);
|
||||
const threads: Thread[] = [];
|
||||
|
||||
for (const tc of threadContainers) {
|
||||
const threadInfo = parseThreadInfo(tc.infoString);
|
||||
if (!threadInfo) continue;
|
||||
|
||||
const commentContainers = parseChildContainers(tc.contentLines);
|
||||
const comments: Comment[] = [];
|
||||
|
||||
for (const cc of commentContainers) {
|
||||
const commentInfo = parseCommentInfo(cc.infoString);
|
||||
if (!commentInfo) continue;
|
||||
|
||||
const { body, reactions } = parseCommentContent(cc.contentLines);
|
||||
|
||||
comments.push({
|
||||
id: commentInfo.id || generateCommentId(),
|
||||
thread_id: threadInfo.id,
|
||||
parent_id: commentInfo.parent_id || null,
|
||||
author: commentInfo.author,
|
||||
date: commentInfo.date,
|
||||
edited_at: commentInfo.edited_at,
|
||||
body,
|
||||
reactions,
|
||||
});
|
||||
}
|
||||
|
||||
threads.push({
|
||||
id: threadInfo.id,
|
||||
resolved: threadInfo.resolved,
|
||||
resolved_by: threadInfo.resolved_by,
|
||||
resolved_at: threadInfo.resolved_at,
|
||||
comments,
|
||||
});
|
||||
}
|
||||
|
||||
return threads;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize threads to a ::: threads block string.
|
||||
*/
|
||||
export function serializeThreadsBlock(threads: Thread[]): string {
|
||||
if (threads.length === 0) return '';
|
||||
|
||||
const lines: string[] = ['::: threads'];
|
||||
|
||||
for (const thread of threads) {
|
||||
let threadLine = ` ::: thread ${thread.id}`;
|
||||
if (thread.resolved && thread.resolved_by && thread.resolved_at) {
|
||||
threadLine += ` resolved "${thread.resolved_by}" "${thread.resolved_at}"`;
|
||||
}
|
||||
lines.push(threadLine);
|
||||
|
||||
for (let ci = 0; ci < thread.comments.length; ci++) {
|
||||
const comment = thread.comments[ci];
|
||||
let commentLine = ` ::: comment ${comment.id} "${comment.author}" "${comment.date}"`;
|
||||
if (comment.parent_id) {
|
||||
commentLine += ` reply-to ${comment.parent_id}`;
|
||||
}
|
||||
if (comment.edited_at) {
|
||||
commentLine += ` edited "${comment.edited_at}"`;
|
||||
}
|
||||
lines.push(commentLine);
|
||||
|
||||
// Indent body to 6 spaces (container depth 3)
|
||||
const bodyLines = comment.body.split('\n');
|
||||
for (const bl of bodyLines) {
|
||||
lines.push(bl === '' ? '' : ` ${bl}`);
|
||||
}
|
||||
|
||||
// Reactions
|
||||
if (comment.reactions && comment.reactions.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(' ::: reactions');
|
||||
for (const r of comment.reactions) {
|
||||
lines.push(` - ${r.emoji} ${r.authors.join(', ')}`);
|
||||
}
|
||||
lines.push(' :::');
|
||||
}
|
||||
|
||||
lines.push(' :::');
|
||||
|
||||
// Blank line between comments (not after the last one)
|
||||
if (ci < thread.comments.length - 1) {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(' :::');
|
||||
}
|
||||
|
||||
lines.push(':::');
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace (or append) the threads block in a full markdown file.
|
||||
*/
|
||||
export function replaceThreadsBlock(markdownContent: string, threads: Thread[]): string {
|
||||
const lines = markdownContent.split('\n');
|
||||
const bounds = findThreadsBlockBounds(lines);
|
||||
const serialized = serializeThreadsBlock(threads);
|
||||
|
||||
if (bounds) {
|
||||
// Replace existing block
|
||||
const before = lines.slice(0, bounds.start);
|
||||
const after = lines.slice(bounds.end + 1);
|
||||
|
||||
if (serialized === '') {
|
||||
// Remove block entirely, trim extra blank lines at boundary
|
||||
const result = [...before, ...after].join('\n');
|
||||
// Clean up double blank lines at the splice point
|
||||
return result.replace(/\n{3,}/g, '\n\n');
|
||||
}
|
||||
|
||||
return [...before, ...serialized.trimEnd().split('\n'), ...after].join('\n');
|
||||
}
|
||||
|
||||
// No existing block - append
|
||||
if (serialized === '') return markdownContent;
|
||||
|
||||
// Ensure there's a blank line before the threads block
|
||||
const trimmedContent = markdownContent.trimEnd();
|
||||
return trimmedContent + '\n\n' + serialized;
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
export interface Author {
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
}
|
||||
|
||||
export interface AuthorsMap {
|
||||
[key: string]: Author;
|
||||
}
|
||||
|
||||
export interface Thread {
|
||||
id: string;
|
||||
resolved: boolean;
|
||||
resolved_by: string | null;
|
||||
resolved_at: string | null;
|
||||
comments: Comment[];
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: string;
|
||||
thread_id: string;
|
||||
parent_id: string | null;
|
||||
author: string;
|
||||
date: string;
|
||||
edited_at: string | null;
|
||||
body: string;
|
||||
reactions: Reaction[];
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
emoji: string;
|
||||
authors: string[];
|
||||
}
|
||||
|
||||
export interface Anchor {
|
||||
quote: string;
|
||||
prefix: string | null;
|
||||
suffix: string | null;
|
||||
selector: string | null;
|
||||
offset: number | null;
|
||||
blockText: string | null;
|
||||
}
|
||||
|
||||
export interface CreateThreadPayload {
|
||||
file: string;
|
||||
anchor: Anchor | null;
|
||||
author: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface AddCommentPayload {
|
||||
file: string;
|
||||
threadId: string;
|
||||
parentId?: string | null;
|
||||
author: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface EditCommentPayload {
|
||||
file: string;
|
||||
threadId: string;
|
||||
commentId: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface DeleteCommentPayload {
|
||||
file: string;
|
||||
threadId: string;
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
export interface ResolveThreadPayload {
|
||||
file: string;
|
||||
threadId: string;
|
||||
resolved_by: string;
|
||||
}
|
||||
|
||||
export interface ToggleReactionPayload {
|
||||
file: string;
|
||||
threadId: string;
|
||||
commentId: string;
|
||||
emoji: string;
|
||||
author: string;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* Tests for threads action handlers
|
||||
*
|
||||
* Run: node packages/plugins/threads/tests/actions.test.js
|
||||
*
|
||||
* @copyright Copyright (c) 2026 Saulo Vallory
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { actions } = require('../src/plugin/actions');
|
||||
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
|
||||
function assert(condition, msg) {
|
||||
total++;
|
||||
if (!condition) {
|
||||
console.error(`FAIL: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
passed++;
|
||||
console.log(` PASS: ${msg}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual, expected, msg) {
|
||||
const a = JSON.stringify(actual);
|
||||
const e = JSON.stringify(expected);
|
||||
assert(a === e, `${msg}\n expected: ${e}\n actual: ${a}`);
|
||||
}
|
||||
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
/**
|
||||
* Create a context object matching what the action dispatcher provides.
|
||||
*/
|
||||
function createCtx(projectRoot) {
|
||||
const ctx = {
|
||||
projectRoot,
|
||||
config: {},
|
||||
broadcast: () => {},
|
||||
_modified: false,
|
||||
source: { _modified: false },
|
||||
async readFile(relativePath) {
|
||||
const resolved = path.resolve(projectRoot, relativePath);
|
||||
return fs.promises.readFile(resolved, 'utf8');
|
||||
},
|
||||
async writeFile(relativePath, content) {
|
||||
const resolved = path.resolve(projectRoot, relativePath);
|
||||
await fs.promises.writeFile(resolved, content);
|
||||
ctx._modified = true;
|
||||
},
|
||||
async readFileLines(relativePath) {
|
||||
const content = await ctx.readFile(relativePath);
|
||||
return content.split('\n');
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an action handler and return { result, reload } like the dispatcher does.
|
||||
*/
|
||||
async function handleCall(actionName, payload, projectRoot) {
|
||||
const handler = actions[actionName];
|
||||
if (!handler) throw new Error(`Unknown action: ${actionName}`);
|
||||
const ctx = createCtx(projectRoot);
|
||||
const result = await handler(payload, ctx);
|
||||
return { result, reload: ctx._modified || ctx.source._modified };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
// Create a temp directory with a test markdown file
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'threads-actions-'));
|
||||
const testFile = 'test.md';
|
||||
const testFilePath = path.join(tempDir, testFile);
|
||||
|
||||
// Write an initial file with no threads
|
||||
fs.writeFileSync(testFilePath, `# Test Document\n\nSome content here.\n`);
|
||||
|
||||
// ─── Test 1: get-threads on empty file → empty array, reload=false ───
|
||||
console.log('\nTest 1: get-threads on empty file');
|
||||
|
||||
const getEmpty = await handleCall('threads:get-threads', { file: testFile }, tempDir);
|
||||
assert(Array.isArray(getEmpty.result), 'get-threads returns an array');
|
||||
assert(getEmpty.result.length === 0, 'get-threads returns empty array for file without threads');
|
||||
assert(getEmpty.reload === false, 'get-threads reload is false (read-only)');
|
||||
|
||||
// ─── Test 2: add-thread → creates thread with ID, reload=true ───
|
||||
console.log('\nTest 2: add-thread creates a thread');
|
||||
|
||||
const addThread1 = await handleCall('threads:add-thread', {
|
||||
file: testFile,
|
||||
author: 'alice',
|
||||
body: 'This needs to be reworded.',
|
||||
anchor: '#section-1',
|
||||
}, tempDir);
|
||||
|
||||
const thread1 = addThread1.result;
|
||||
assert(addThread1.reload === true, 'add-thread reload is true');
|
||||
assert(thread1.id.startsWith('t-'), 'thread id starts with t-');
|
||||
assert(thread1.id.length === 10, 'thread id is t- + 8 hex chars');
|
||||
assert(thread1.resolved === false, 'new thread is not resolved');
|
||||
assert(thread1.comments.length === 1, 'new thread has 1 comment');
|
||||
assert(thread1.comments[0].author === 'alice', 'first comment author is alice');
|
||||
assert(thread1.comments[0].body === 'This needs to be reworded.', 'first comment body matches');
|
||||
assert(thread1.comments[0].date === today, 'first comment date is today');
|
||||
assert(thread1.comments[0].id.startsWith('c-'), 'comment id starts with c-');
|
||||
assert(thread1.comments[0].id.length === 10, 'comment id is c- + 8 hex chars');
|
||||
|
||||
// ─── Test 3: add-comment → appends to thread, reload=true ───
|
||||
console.log('\nTest 3: add-comment appends to thread');
|
||||
|
||||
const addComment = await handleCall('threads:add-comment', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
author: 'bob',
|
||||
body: 'I agree, let me fix it.',
|
||||
}, tempDir);
|
||||
|
||||
const newComment = addComment.result;
|
||||
assert(addComment.reload === true, 'add-comment reload is true');
|
||||
assert(newComment.id.startsWith('c-'), 'new comment id starts with c-');
|
||||
assert(newComment.author === 'bob', 'new comment author is bob');
|
||||
assert(newComment.body === 'I agree, let me fix it.', 'new comment body matches');
|
||||
assert(newComment.date === today, 'new comment date is today');
|
||||
assert(newComment.thread_id === thread1.id, 'new comment thread_id matches');
|
||||
|
||||
// ─── Test 4: get-threads after adds → returns correct data ───
|
||||
console.log('\nTest 4: get-threads after adds');
|
||||
|
||||
const getAfterAdds = await handleCall('threads:get-threads', { file: testFile }, tempDir);
|
||||
assert(getAfterAdds.result.length === 1, 'still has 1 thread');
|
||||
assert(getAfterAdds.result[0].comments.length === 2, 'thread now has 2 comments');
|
||||
assert(getAfterAdds.result[0].comments[0].author === 'alice', 'first comment is alice');
|
||||
assert(getAfterAdds.result[0].comments[1].author === 'bob', 'second comment is bob');
|
||||
|
||||
// ─── Test 5: edit-comment → updates body + edited_at, reload=true ───
|
||||
console.log('\nTest 5: edit-comment');
|
||||
|
||||
// Need to get the actual comment ID from the file (parser generates new IDs on parse)
|
||||
const preEdit = await handleCall('threads:get-threads', { file: testFile }, tempDir);
|
||||
const bobCommentIdForEdit = preEdit.result[0].comments[1].id;
|
||||
|
||||
const editResult = await handleCall('threads:edit-comment', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
commentId: bobCommentIdForEdit,
|
||||
body: 'Updated: I will fix it tomorrow.',
|
||||
}, tempDir);
|
||||
|
||||
assert(editResult.reload === true, 'edit-comment reload is true');
|
||||
assert(editResult.result.body === 'Updated: I will fix it tomorrow.', 'edited body matches');
|
||||
assert(editResult.result.edited_at === today, 'edited_at is set to today');
|
||||
|
||||
// Verify via get-threads
|
||||
const afterEdit = await handleCall('threads:get-threads', { file: testFile }, tempDir);
|
||||
assert(afterEdit.result[0].comments[1].body === 'Updated: I will fix it tomorrow.', 'get-threads reflects edit');
|
||||
assert(afterEdit.result[0].comments[1].edited_at === today, 'get-threads reflects edited_at');
|
||||
|
||||
// ─── Test 6: toggle-reaction → add then remove, reload=true ───
|
||||
console.log('\nTest 6: toggle-reaction');
|
||||
|
||||
const firstCommentId = afterEdit.result[0].comments[0].id;
|
||||
|
||||
// Add a reaction
|
||||
const addReaction = await handleCall('threads:toggle-reaction', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
commentId: firstCommentId,
|
||||
emoji: '👍',
|
||||
author: 'bob',
|
||||
}, tempDir);
|
||||
|
||||
assert(addReaction.reload === true, 'toggle-reaction reload is true');
|
||||
assert(addReaction.result.length === 1, 'one reaction after adding');
|
||||
assert(addReaction.result[0].emoji === '👍', 'reaction emoji matches');
|
||||
assertDeepEqual(addReaction.result[0].authors, ['bob'], 'reaction authors after add');
|
||||
|
||||
// Add same emoji from another author
|
||||
const addReaction2 = await handleCall('threads:toggle-reaction', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
commentId: firstCommentId,
|
||||
emoji: '👍',
|
||||
author: 'charlie',
|
||||
}, tempDir);
|
||||
|
||||
assert(addReaction2.result.length === 1, 'still one reaction type');
|
||||
assertDeepEqual(addReaction2.result[0].authors, ['bob', 'charlie'], 'reaction authors after second add');
|
||||
|
||||
// Remove (toggle off) bob's reaction
|
||||
const removeReaction = await handleCall('threads:toggle-reaction', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
commentId: firstCommentId,
|
||||
emoji: '👍',
|
||||
author: 'bob',
|
||||
}, tempDir);
|
||||
|
||||
assert(removeReaction.result.length === 1, 'still one reaction after removing one author');
|
||||
assertDeepEqual(removeReaction.result[0].authors, ['charlie'], 'bob removed from authors');
|
||||
|
||||
// Remove last author → reaction removed entirely
|
||||
const removeLastReaction = await handleCall('threads:toggle-reaction', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
commentId: firstCommentId,
|
||||
emoji: '👍',
|
||||
author: 'charlie',
|
||||
}, tempDir);
|
||||
|
||||
assert(removeLastReaction.result.length === 0, 'reaction removed entirely when no authors left');
|
||||
|
||||
// ─── Test 7: resolve-thread → toggles resolved status ───
|
||||
console.log('\nTest 7: resolve-thread toggles');
|
||||
|
||||
const resolveResult = await handleCall('threads:resolve-thread', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
resolved_by: 'alice',
|
||||
}, tempDir);
|
||||
|
||||
assert(resolveResult.reload === true, 'resolve-thread reload is true');
|
||||
assert(resolveResult.result.resolved === true, 'thread is now resolved');
|
||||
assert(resolveResult.result.resolved_by === 'alice', 'resolved_by is alice');
|
||||
assert(resolveResult.result.resolved_at === today, 'resolved_at is today');
|
||||
|
||||
// Toggle back (unresolve)
|
||||
const unresolveResult = await handleCall('threads:resolve-thread', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
resolved_by: 'alice',
|
||||
}, tempDir);
|
||||
|
||||
assert(unresolveResult.result.resolved === false, 'thread is now unresolved');
|
||||
assert(unresolveResult.result.resolved_by === null, 'resolved_by cleared');
|
||||
assert(unresolveResult.result.resolved_at === null, 'resolved_at cleared');
|
||||
|
||||
// ─── Test 8: delete-comment → removes comment ───
|
||||
console.log('\nTest 8: delete-comment');
|
||||
|
||||
// Get current state to know comment IDs
|
||||
const beforeDelete = await handleCall('threads:get-threads', { file: testFile }, tempDir);
|
||||
const bobCommentId = beforeDelete.result[0].comments[1].id;
|
||||
|
||||
const deleteComment = await handleCall('threads:delete-comment', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
commentId: bobCommentId,
|
||||
}, tempDir);
|
||||
|
||||
assert(deleteComment.reload === true, 'delete-comment reload is true');
|
||||
assertDeepEqual(deleteComment.result, { deleted: true }, 'delete-comment returns { deleted: true }');
|
||||
|
||||
const afterDeleteComment = await handleCall('threads:get-threads', { file: testFile }, tempDir);
|
||||
assert(afterDeleteComment.result[0].comments.length === 1, 'thread has 1 comment after delete');
|
||||
assert(afterDeleteComment.result[0].comments[0].author === 'alice', 'remaining comment is alice');
|
||||
|
||||
// ─── Test 9: delete-thread → removes thread ───
|
||||
console.log('\nTest 9: delete-thread');
|
||||
|
||||
const deleteThread = await handleCall('threads:delete-thread', {
|
||||
file: testFile,
|
||||
threadId: thread1.id,
|
||||
}, tempDir);
|
||||
|
||||
assert(deleteThread.reload === true, 'delete-thread reload is true');
|
||||
assertDeepEqual(deleteThread.result, { deleted: true }, 'delete-thread returns { deleted: true }');
|
||||
|
||||
const afterDeleteThread = await handleCall('threads:get-threads', { file: testFile }, tempDir);
|
||||
assert(afterDeleteThread.result.length === 0, 'no threads after delete');
|
||||
|
||||
// Verify the file content no longer has a threads block
|
||||
const finalContent = fs.readFileSync(testFilePath, 'utf8');
|
||||
assert(finalContent.includes('# Test Document'), 'original content preserved');
|
||||
assert(!finalContent.includes('::: threads'), 'threads block removed from file');
|
||||
|
||||
// ─── Test 10: add-thread with anchor → wraps quote with highlight markup ───
|
||||
console.log('\nTest 10: add-thread with anchor (highlight creation)');
|
||||
|
||||
const anchorFile = 'anchor-test.md';
|
||||
fs.writeFileSync(path.join(tempDir, anchorFile), '# Test\n\nSome important text in a paragraph.\n');
|
||||
|
||||
const addThreadAnchor = await handleCall('threads:add-thread', {
|
||||
file: anchorFile,
|
||||
author: 'alice',
|
||||
body: 'This is important',
|
||||
anchor: {
|
||||
quote: 'important text',
|
||||
prefix: 'Some ',
|
||||
suffix: ' in a',
|
||||
selector: 'p',
|
||||
offset: 5,
|
||||
blockText: 'Some important text in a paragraph.',
|
||||
},
|
||||
}, tempDir);
|
||||
|
||||
const anchorThread = addThreadAnchor.result;
|
||||
assert(addThreadAnchor.reload === true, 'add-thread with anchor reload is true');
|
||||
const anchorContent = fs.readFileSync(path.join(tempDir, anchorFile), 'utf8');
|
||||
assert(anchorContent.includes(`==important text=={${anchorThread.id}}`), `Should have highlight markup. Got:\n${anchorContent}`);
|
||||
assert(anchorContent.includes('::: threads'), 'Should have threads block');
|
||||
const highlightIdx = anchorContent.indexOf(`==important text=={${anchorThread.id}}`);
|
||||
const threadsBlockIdx = anchorContent.indexOf('::: threads');
|
||||
assert(highlightIdx < threadsBlockIdx, 'Highlight should be before threads block');
|
||||
|
||||
// ─── Test 11: add-thread with anchor doesn't match inside threads block ───
|
||||
console.log('\nTest 11: add-thread with anchor avoids matching inside threads block');
|
||||
|
||||
const anchorFile2 = 'anchor-test2.md';
|
||||
// Write a file where the quote text appears in both body and an existing thread comment
|
||||
fs.writeFileSync(path.join(tempDir, anchorFile2), [
|
||||
'# Test',
|
||||
'',
|
||||
'Some unique phrase here.',
|
||||
'',
|
||||
'::: threads',
|
||||
'### t-existing',
|
||||
'- resolved: false',
|
||||
'',
|
||||
'#### c-existing',
|
||||
'- author: bob',
|
||||
'- date: 2026-01-01',
|
||||
'',
|
||||
'Some unique phrase here.',
|
||||
'',
|
||||
':::',
|
||||
'',
|
||||
].join('\n'));
|
||||
|
||||
const addThreadAnchor2 = await handleCall('threads:add-thread', {
|
||||
file: anchorFile2,
|
||||
author: 'alice',
|
||||
body: 'Noting this phrase',
|
||||
anchor: {
|
||||
quote: 'unique phrase',
|
||||
},
|
||||
}, tempDir);
|
||||
|
||||
const anchorContent2 = fs.readFileSync(path.join(tempDir, anchorFile2), 'utf8');
|
||||
const anchorThread2 = addThreadAnchor2.result;
|
||||
// The highlight should appear in the body, before the threads block
|
||||
const highlightIdx2 = anchorContent2.indexOf(`==unique phrase=={${anchorThread2.id}}`);
|
||||
const threadsBlockIdx2 = anchorContent2.indexOf('::: threads');
|
||||
assert(highlightIdx2 >= 0, `Highlight markup should exist. Got:\n${anchorContent2}`);
|
||||
assert(highlightIdx2 < threadsBlockIdx2, 'Highlight should be in body, not in threads block');
|
||||
|
||||
// ─── Test 12: delete-thread removes highlight markup from body ───
|
||||
console.log('\nTest 12: delete-thread removes highlight markup');
|
||||
|
||||
const deleteHighlightFile = 'delete-highlight.md';
|
||||
const deleteThreadId = anchorThread.id;
|
||||
// Copy the anchor file content which has ==important text=={threadId}
|
||||
fs.writeFileSync(path.join(tempDir, deleteHighlightFile), anchorContent);
|
||||
|
||||
await handleCall('threads:delete-thread', {
|
||||
file: deleteHighlightFile,
|
||||
threadId: deleteThreadId,
|
||||
}, tempDir);
|
||||
|
||||
const afterDelete = fs.readFileSync(path.join(tempDir, deleteHighlightFile), 'utf8');
|
||||
assert(!afterDelete.includes('==' + 'important text=={'), 'Highlight markup should be removed');
|
||||
assert(afterDelete.includes('important text'), 'Original text should remain');
|
||||
|
||||
// ─── Test 13: input validation ───
|
||||
console.log('\nTest 13: input validation');
|
||||
|
||||
let validationError;
|
||||
|
||||
try {
|
||||
await handleCall('threads:add-thread', { file: '', author: 'alice', body: 'hi' }, tempDir);
|
||||
} catch (e) {
|
||||
validationError = e.message;
|
||||
}
|
||||
assert(validationError && validationError.includes('file is required'), 'Should reject empty file');
|
||||
|
||||
validationError = null;
|
||||
try {
|
||||
await handleCall('threads:add-thread', { file: testFile, author: '', body: 'hi' }, tempDir);
|
||||
} catch (e) {
|
||||
validationError = e.message;
|
||||
}
|
||||
assert(validationError && validationError.includes('author is required'), 'Should reject empty author');
|
||||
|
||||
validationError = null;
|
||||
try {
|
||||
await handleCall('threads:add-thread', { file: testFile, author: 'alice', body: ' ' }, tempDir);
|
||||
} catch (e) {
|
||||
validationError = e.message;
|
||||
}
|
||||
assert(validationError && validationError.includes('body is required'), 'Should reject whitespace-only body');
|
||||
|
||||
validationError = null;
|
||||
try {
|
||||
await handleCall('threads:get-threads', {}, tempDir);
|
||||
} catch (e) {
|
||||
validationError = e.message;
|
||||
}
|
||||
assert(validationError && validationError.includes('file is required'), 'get-threads should reject missing file');
|
||||
|
||||
// ─── Cleanup ───
|
||||
fs.rmSync(tempDir, { recursive: true });
|
||||
|
||||
console.log(`\n✓ All ${passed}/${total} tests passed.\n`);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error('Test error:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Tests for threads container rules - markdown-it rendering of ::: threads blocks
|
||||
*
|
||||
* Run: node packages/plugins/threads/tests/containers.test.js
|
||||
*
|
||||
* @copyright Copyright (c) 2026 Saulo Vallory
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import MarkdownIt from 'markdown-it';
|
||||
import { setup as setupContainers } from '../dist/plugin/containers.js';
|
||||
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
|
||||
function assert(condition, msg) {
|
||||
total++;
|
||||
if (!condition) {
|
||||
console.error(`FAIL: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
passed++;
|
||||
console.log(` PASS: ${msg}`);
|
||||
}
|
||||
|
||||
function assertContains(html, substring, msg) {
|
||||
assert(html.includes(substring), `${msg}\n expected to contain: ${substring}\n actual: ${html.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
function assertNotContains(html, substring, msg) {
|
||||
assert(!html.includes(substring), `${msg}\n expected NOT to contain: ${substring}\n actual: ${html.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
function createMd() {
|
||||
const md = MarkdownIt({ html: true });
|
||||
setupContainers(md);
|
||||
return md;
|
||||
}
|
||||
|
||||
// ─── Test 1: Full threads block renders with correct structure ───
|
||||
|
||||
console.log('\nTest 1: Full threads block renders correct HTML structure');
|
||||
|
||||
const md1 = createMd();
|
||||
const fullInput = `::: threads
|
||||
::: thread t-abc123
|
||||
::: comment "alice" "2026-03-07"
|
||||
This is the first comment
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html1 = md1.render(fullInput);
|
||||
|
||||
assertContains(html1, 'class="threads-sidebar"', 'threads wrapper has correct class');
|
||||
assertContains(html1, 'class="threads-thread"', 'thread has correct class');
|
||||
assertContains(html1, 'data-thread-id="t-abc123"', 'thread has data-thread-id');
|
||||
assertContains(html1, 'class="threads-comment"', 'comment has correct class');
|
||||
assertContains(html1, 'data-author="alice"', 'comment has data-author');
|
||||
assertContains(html1, 'data-date="2026-03-07"', 'comment has data-date');
|
||||
assertContains(html1, 'class="threads-comment__meta"', 'comment meta renders');
|
||||
assertContains(html1, 'class="threads-comment__body"', 'comment body wrapper renders');
|
||||
assertContains(html1, 'This is the first comment', 'comment body content renders');
|
||||
|
||||
// ─── Test 2: Thread ID attribute is present ───
|
||||
|
||||
console.log('\nTest 2: Thread id attribute');
|
||||
|
||||
const md2 = createMd();
|
||||
const idInput = `::: threads
|
||||
::: thread t-xyz789
|
||||
::: comment "bob" "2026-01-01"
|
||||
Test
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html2 = md2.render(idInput);
|
||||
assertContains(html2, 'data-thread-id="t-xyz789"', 'thread id attribute is present and correct');
|
||||
|
||||
// ─── Test 3: Resolved thread has resolved class ───
|
||||
|
||||
console.log('\nTest 3: Resolved thread has resolved class');
|
||||
|
||||
const md3 = createMd();
|
||||
const resolvedInput = `::: threads
|
||||
::: thread t-res001 resolved "charlie" "2026-03-08"
|
||||
::: comment "charlie" "2026-03-08"
|
||||
Fixed the typo
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html3 = md3.render(resolvedInput);
|
||||
assertContains(html3, 'threads-thread--resolved', 'resolved thread has resolved class');
|
||||
assertContains(html3, 'data-thread-id="t-res001"', 'resolved thread has id attribute');
|
||||
|
||||
// ─── Test 4: Comment has data-author and data-date ───
|
||||
|
||||
console.log('\nTest 4: Comment data attributes');
|
||||
|
||||
const md4 = createMd();
|
||||
const commentInput = `::: threads
|
||||
::: thread t-c001
|
||||
::: comment "dave" "2026-05-15"
|
||||
Comment with attributes
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html4 = md4.render(commentInput);
|
||||
assertContains(html4, 'data-author="dave"', 'comment has data-author');
|
||||
assertContains(html4, 'data-date="2026-05-15"', 'comment has data-date');
|
||||
|
||||
// ─── Test 5: Comment meta header renders author and date ───
|
||||
|
||||
console.log('\nTest 5: Comment meta header');
|
||||
|
||||
const md5 = createMd();
|
||||
const metaInput = `::: threads
|
||||
::: thread t-m001
|
||||
::: comment "eve" "2026-06-20"
|
||||
Testing meta
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html5 = md5.render(metaInput);
|
||||
assertContains(html5, '<strong>eve</strong>', 'meta includes author in strong tag');
|
||||
assertContains(html5, '2026-06-20', 'meta includes date');
|
||||
|
||||
// ─── Test 6: Reactions container renders ───
|
||||
|
||||
console.log('\nTest 6: Reactions container');
|
||||
|
||||
const md6 = createMd();
|
||||
const reactionsInput = `::: threads
|
||||
::: thread t-r001
|
||||
::: comment "frank" "2026-07-01"
|
||||
Comment with reactions
|
||||
|
||||
::: reactions
|
||||
- 👍 alice, bob
|
||||
- 🎉 charlie
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html6 = md6.render(reactionsInput);
|
||||
assertContains(html6, 'class="threads-reactions"', 'reactions container has correct class');
|
||||
|
||||
// ─── Test 7: Multiple threads render ───
|
||||
|
||||
console.log('\nTest 7: Multiple threads');
|
||||
|
||||
const md7 = createMd();
|
||||
const multiInput = `::: threads
|
||||
::: thread t-first
|
||||
::: comment "alice" "2026-01-01"
|
||||
First thread
|
||||
:::
|
||||
:::
|
||||
|
||||
::: thread t-second
|
||||
::: comment "bob" "2026-01-02"
|
||||
Second thread
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html7 = md7.render(multiInput);
|
||||
assertContains(html7, 'data-thread-id="t-first"', 'first thread renders');
|
||||
assertContains(html7, 'data-thread-id="t-second"', 'second thread renders');
|
||||
|
||||
// ─── Test 8: Unresolved thread does NOT have resolved class ───
|
||||
|
||||
console.log('\nTest 8: Unresolved thread lacks resolved class');
|
||||
|
||||
const md8 = createMd();
|
||||
const unresolvedInput = `::: threads
|
||||
::: thread t-unres
|
||||
::: comment "alice" "2026-01-01"
|
||||
Not resolved
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html8 = md8.render(unresolvedInput);
|
||||
assertContains(html8, 'data-thread-id="t-unres"', 'unresolved thread renders');
|
||||
assertNotContains(html8, 'threads-thread--resolved', 'unresolved thread does not have resolved class');
|
||||
|
||||
// ─── Test 9: Edited comment has edited data attribute ───
|
||||
|
||||
console.log('\nTest 9: Edited comment');
|
||||
|
||||
const md9 = createMd();
|
||||
const editedInput = `::: threads
|
||||
::: thread t-ed01
|
||||
::: comment "alice" "2026-01-01" edited "2026-01-02"
|
||||
Edited comment
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html9 = md9.render(editedInput);
|
||||
assertContains(html9, 'data-edited="2026-01-02"', 'edited comment has data-edited attribute');
|
||||
|
||||
// ─── Test 10: Comment body is rendered as markdown ───
|
||||
|
||||
console.log('\nTest 10: Comment body rendered as markdown');
|
||||
|
||||
const md10 = createMd();
|
||||
const markdownBodyInput = `::: threads
|
||||
::: thread t-md01
|
||||
::: comment "alice" "2026-01-01"
|
||||
This has **bold** and *italic* text
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html10 = md10.render(markdownBodyInput);
|
||||
assertContains(html10, '<strong>bold</strong>', 'bold markdown renders in comment body');
|
||||
assertContains(html10, '<em>italic</em>', 'italic markdown renders in comment body');
|
||||
|
||||
// ─── Test 11: Comment body escapes raw HTML while preserving markdown ───
|
||||
|
||||
console.log('\nTest 11: Comment body escapes raw HTML');
|
||||
|
||||
const md11 = createMd();
|
||||
const rawHtmlInput = `::: threads
|
||||
::: thread t-html01
|
||||
::: comment "alice" "2026-01-01"
|
||||
<span data-test="raw">raw html</span>
|
||||
This still has **bold** text
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html11 = md11.render(rawHtmlInput);
|
||||
assert(!html11.includes('<span data-test="raw">raw html</span>'), 'raw HTML is not emitted as an element');
|
||||
assert(html11.includes('<span') || html11.includes('&lt;span'), 'raw HTML tag marker is escaped');
|
||||
assertContains(html11, '<strong>bold</strong>', 'markdown still renders in escaped comment body');
|
||||
|
||||
// ─── Test 12: Empty threads block ───
|
||||
|
||||
console.log('\nTest 12: Empty threads block');
|
||||
|
||||
const md12 = createMd();
|
||||
const emptyInput = `::: threads
|
||||
:::
|
||||
`;
|
||||
|
||||
const html12 = md12.render(emptyInput);
|
||||
assertContains(html12, 'class="threads-sidebar"', 'empty threads wrapper still renders');
|
||||
|
||||
// ─── Test 13: Comment with ID (new serialized format) ───
|
||||
|
||||
console.log('\nTest 13: Comment with ID in info string');
|
||||
|
||||
const md13 = createMd();
|
||||
const commentWithIdInput = `::: threads
|
||||
::: thread t-id01
|
||||
::: comment c-abc12345 "alice" "2026-03-07"
|
||||
Comment with persistent ID
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html13 = md13.render(commentWithIdInput);
|
||||
assertContains(html13, 'data-comment-id="c-abc12345"', 'comment has data-comment-id from new format');
|
||||
assertContains(html13, 'data-author="alice"', 'comment has correct author from new format');
|
||||
assertContains(html13, 'data-date="2026-03-07"', 'comment has correct date from new format');
|
||||
|
||||
// ─── Test 14: Comment with ID and edited (new serialized format) ───
|
||||
|
||||
console.log('\nTest 14: Comment with ID and edited');
|
||||
|
||||
const md14 = createMd();
|
||||
const commentWithIdEditedInput = `::: threads
|
||||
::: thread t-id02
|
||||
::: comment c-def67890 "bob" "2026-03-07" edited "2026-03-08"
|
||||
Edited comment with ID
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const html14 = md14.render(commentWithIdEditedInput);
|
||||
assertContains(html14, 'data-comment-id="c-def67890"', 'edited comment has data-comment-id');
|
||||
assertContains(html14, 'data-author="bob"', 'edited comment has correct author');
|
||||
assertContains(html14, 'data-edited="2026-03-08"', 'edited comment has data-edited');
|
||||
|
||||
// ─── Done ───
|
||||
|
||||
console.log(`\n✓ All ${passed}/${total} containers tests passed.\n`);
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Tests for highlight inline rule - ==text=={thread-id} syntax
|
||||
*
|
||||
* Run: node packages/plugins/threads/tests/highlight-rule.test.js
|
||||
*/
|
||||
|
||||
const md = require('markdown-it')();
|
||||
const highlightRule = require('../src/plugin/highlight-rule');
|
||||
highlightRule.setup(md);
|
||||
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
|
||||
function assert(condition, msg) {
|
||||
total++;
|
||||
if (!condition) {
|
||||
console.error(`FAIL: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
passed++;
|
||||
console.log(` PASS: ${msg}`);
|
||||
}
|
||||
|
||||
// ─── Test 1: Basic highlight with thread ID ───
|
||||
|
||||
console.log('\nTest 1: Basic highlight with thread ID');
|
||||
|
||||
const result1 = md.render('==highlighted text=={t-abc123}');
|
||||
assert(
|
||||
result1.includes('<mark class="threads-highlight" data-thread-id="t-abc123">highlighted text</mark>'),
|
||||
'renders mark with class and data-thread-id attribute'
|
||||
);
|
||||
|
||||
// ─── Test 2: Plain highlight without thread ID ───
|
||||
|
||||
console.log('\nTest 2: Plain highlight without thread ID');
|
||||
|
||||
const result2 = md.render('==plain highlight==');
|
||||
assert(
|
||||
result2.includes('<mark class="threads-highlight">plain highlight</mark>'),
|
||||
'renders mark with class but no data-thread-id'
|
||||
);
|
||||
assert(
|
||||
!result2.includes('data-thread-id'),
|
||||
'no data-thread-id attribute present'
|
||||
);
|
||||
|
||||
// ─── Test 3: Multiple highlights in one line ───
|
||||
|
||||
console.log('\nTest 3: Multiple highlights in one line');
|
||||
|
||||
const result3 = md.render('==first=={t-1} and ==second=={t-2}');
|
||||
assert(
|
||||
result3.includes('<mark class="threads-highlight" data-thread-id="t-1">first</mark>'),
|
||||
'first highlight rendered correctly'
|
||||
);
|
||||
assert(
|
||||
result3.includes('<mark class="threads-highlight" data-thread-id="t-2">second</mark>'),
|
||||
'second highlight rendered correctly'
|
||||
);
|
||||
assert(
|
||||
result3.includes(' and '),
|
||||
'text between highlights preserved'
|
||||
);
|
||||
|
||||
// ─── Test 4: Highlight inside a paragraph with other text ───
|
||||
|
||||
console.log('\nTest 4: Highlight inside a paragraph');
|
||||
|
||||
const result4 = md.render('This is a ==highlighted phrase=={t-42} in a sentence.');
|
||||
assert(
|
||||
result4.includes('This is a <mark class="threads-highlight" data-thread-id="t-42">highlighted phrase</mark> in a sentence.'),
|
||||
'highlight embedded in paragraph text'
|
||||
);
|
||||
|
||||
// ─── Test 5: Highlight with bold inside ───
|
||||
|
||||
console.log('\nTest 5: Highlight with bold inside');
|
||||
|
||||
const result5 = md.render('==**bold** text=={t-1}');
|
||||
assert(
|
||||
result5.includes('<mark class="threads-highlight" data-thread-id="t-1">'),
|
||||
'mark open tag with thread id'
|
||||
);
|
||||
assert(
|
||||
result5.includes('<strong>bold</strong>'),
|
||||
'bold rendered inside mark'
|
||||
);
|
||||
assert(
|
||||
result5.includes('</mark>'),
|
||||
'mark close tag present'
|
||||
);
|
||||
|
||||
// ─── Test 6: No match for single = ───
|
||||
|
||||
console.log('\nTest 6: No false positive on single =');
|
||||
|
||||
const result6 = md.render('a = b and c = d');
|
||||
assert(
|
||||
!result6.includes('<mark'),
|
||||
'single = signs do not trigger highlight'
|
||||
);
|
||||
|
||||
// ─── Test 7: Empty highlight text is not matched ───
|
||||
|
||||
console.log('\nTest 7: Empty highlight text');
|
||||
|
||||
const result7 = md.render('===={t-1}');
|
||||
assert(
|
||||
!result7.includes('<mark'),
|
||||
'empty content between == markers does not match'
|
||||
);
|
||||
|
||||
// ─── Test 8: Highlight does not span across line breaks ───
|
||||
|
||||
console.log('\nTest 8: No match across line breaks');
|
||||
|
||||
const result8 = md.render('==start\nend==');
|
||||
assert(
|
||||
!result8.includes('<mark'),
|
||||
'highlight does not span newlines'
|
||||
);
|
||||
|
||||
// ─── Test 9: Escaped == is not matched ───
|
||||
|
||||
console.log('\nTest 9: Backslash-escaped markers');
|
||||
|
||||
const result9 = md.render('\\==not highlighted==');
|
||||
assert(
|
||||
!result9.includes('<mark'),
|
||||
'escaped opening == is not treated as highlight'
|
||||
);
|
||||
|
||||
// ─── Done ───
|
||||
|
||||
console.log(`\n✓ All ${passed}/${total} tests passed.\n`);
|
||||
@@ -0,0 +1,426 @@
|
||||
/**
|
||||
* Integration test - full round-trip for threads plugin
|
||||
*
|
||||
* Verifies: plugin loading, action handlers, markdown serialization,
|
||||
* container rendering, and highlight rule.
|
||||
*
|
||||
* Run: node packages/plugins/threads/tests/integration.test.js
|
||||
*
|
||||
* @copyright Copyright (c) 2026 Saulo Vallory
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
let tempDir = null;
|
||||
|
||||
function assert(condition, msg) {
|
||||
total++;
|
||||
if (!condition) {
|
||||
console.error(`FAIL: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
passed++;
|
||||
console.log(` PASS: ${msg}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal action context (avoids broken createActionDispatcher dependency)
|
||||
// ---------------------------------------------------------------------------
|
||||
function createMinimalCtx(projectRoot) {
|
||||
return {
|
||||
projectRoot,
|
||||
config: {},
|
||||
broadcast: () => {},
|
||||
_modified: false,
|
||||
async readFile(relativePath) {
|
||||
return fs.promises.readFile(path.join(projectRoot, relativePath), 'utf8');
|
||||
},
|
||||
async writeFile(relativePath, content) {
|
||||
await fs.promises.writeFile(path.join(projectRoot, relativePath), content);
|
||||
this._modified = true;
|
||||
},
|
||||
async readFileLines(relativePath) {
|
||||
const content = await this.readFile(relativePath);
|
||||
return content.split('\n');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function testPluginLoads() {
|
||||
console.log('\n--- Plugin loading ---');
|
||||
const plugin = require('..');
|
||||
assert(typeof plugin.markdownSetup === 'function', 'markdownSetup is a function');
|
||||
assert(typeof plugin.generateScripts === 'function', 'generateScripts is a function');
|
||||
assert(typeof plugin.getAssets === 'function', 'getAssets is a function');
|
||||
assert(typeof plugin.actions === 'object' && plugin.actions !== null, 'actions is an object');
|
||||
assert(typeof plugin.actions['threads:add-thread'] === 'function', 'add-thread action exists');
|
||||
assert(typeof plugin.actions['threads:add-comment'] === 'function', 'add-comment action exists');
|
||||
assert(typeof plugin.actions['threads:toggle-reaction'] === 'function', 'toggle-reaction action exists');
|
||||
assert(typeof plugin.actions['threads:get-threads'] === 'function', 'get-threads action exists');
|
||||
return plugin;
|
||||
}
|
||||
|
||||
async function testCreateThread(plugin, ctx) {
|
||||
console.log('\n--- Create thread ---');
|
||||
const result = await plugin.actions['threads:add-thread'](
|
||||
{ file: 'test.md', author: 'Alice', body: 'This needs work.', anchor: 'some-anchor' },
|
||||
ctx
|
||||
);
|
||||
|
||||
assert(typeof result.id === 'string' && result.id.startsWith('t-'), 'Thread ID starts with t-');
|
||||
assert(result.id.length === 10, 'Thread ID is t- + 8 hex chars');
|
||||
assert(result.resolved === false, 'Thread is not resolved');
|
||||
assert(Array.isArray(result.comments), 'Thread has comments array');
|
||||
assert(result.comments.length === 1, 'Thread has exactly one comment');
|
||||
assert(result.comments[0].author === 'Alice', 'First comment author is Alice');
|
||||
assert(result.comments[0].body === 'This needs work.', 'First comment body matches');
|
||||
assert(
|
||||
typeof result.comments[0].id === 'string' && result.comments[0].id.startsWith('c-'),
|
||||
'Comment ID starts with c-'
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function testAddComment(plugin, ctx, threadId) {
|
||||
console.log('\n--- Add comment ---');
|
||||
const comment = await plugin.actions['threads:add-comment'](
|
||||
{ file: 'test.md', threadId, author: 'Bob', body: 'I agree, let me fix it.' },
|
||||
ctx
|
||||
);
|
||||
|
||||
assert(typeof comment.id === 'string' && comment.id.startsWith('c-'), 'Comment ID starts with c-');
|
||||
assert(comment.author === 'Bob', 'Comment author is Bob');
|
||||
assert(comment.body === 'I agree, let me fix it.', 'Comment body matches');
|
||||
assert(comment.thread_id === threadId, 'Comment thread_id matches');
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
async function testToggleReaction(plugin, ctx, threadId, commentId) {
|
||||
console.log('\n--- Toggle reaction ---');
|
||||
const reactions = await plugin.actions['threads:toggle-reaction'](
|
||||
{ file: 'test.md', threadId, commentId, emoji: '👍', author: 'Alice' },
|
||||
ctx
|
||||
);
|
||||
|
||||
assert(Array.isArray(reactions), 'Reactions is an array');
|
||||
assert(reactions.length === 1, 'One reaction');
|
||||
assert(reactions[0].emoji === '👍', 'Reaction emoji is thumbs up');
|
||||
assert(reactions[0].authors.includes('Alice'), 'Alice is in reaction authors');
|
||||
|
||||
// Add a second author to the same reaction
|
||||
const reactions2 = await plugin.actions['threads:toggle-reaction'](
|
||||
{ file: 'test.md', threadId, commentId, emoji: '👍', author: 'Charlie' },
|
||||
ctx
|
||||
);
|
||||
assert(reactions2[0].authors.length === 2, 'Two authors on the reaction');
|
||||
assert(reactions2[0].authors.includes('Charlie'), 'Charlie is in reaction authors');
|
||||
|
||||
return reactions2;
|
||||
}
|
||||
|
||||
async function testGetThreads(plugin, ctx, threadId, secondCommentId) {
|
||||
console.log('\n--- Get threads (read back) ---');
|
||||
const threads = await plugin.actions['threads:get-threads'](
|
||||
{ file: 'test.md' },
|
||||
ctx
|
||||
);
|
||||
|
||||
assert(Array.isArray(threads), 'Result is an array');
|
||||
assert(threads.length === 1, 'One thread exists');
|
||||
|
||||
const thread = threads[0];
|
||||
assert(thread.id === threadId, 'Thread ID matches');
|
||||
assert(thread.resolved === false, 'Thread is not resolved');
|
||||
assert(thread.comments.length === 2, 'Thread has two comments');
|
||||
assert(thread.comments[0].author === 'Alice', 'First comment by Alice');
|
||||
assert(thread.comments[1].author === 'Bob', 'Second comment by Bob');
|
||||
assert(thread.comments[1].id === secondCommentId, 'Second comment ID matches');
|
||||
|
||||
// Check reaction persisted
|
||||
const bobComment = thread.comments[1];
|
||||
assert(bobComment.reactions.length === 1, 'Bob comment has one reaction');
|
||||
assert(bobComment.reactions[0].emoji === '👍', 'Reaction emoji persisted');
|
||||
assert(bobComment.reactions[0].authors.length === 2, 'Reaction has two authors');
|
||||
|
||||
return threads;
|
||||
}
|
||||
|
||||
async function testMarkdownFileContent(ctx) {
|
||||
console.log('\n--- Markdown file content ---');
|
||||
const content = await ctx.readFile('test.md');
|
||||
|
||||
// Original content preserved
|
||||
assert(content.includes('# Hello World'), 'Original heading preserved');
|
||||
assert(content.includes('Some content here.'), 'Original body preserved');
|
||||
|
||||
// Threads block structure
|
||||
assert(content.includes('::: threads'), 'File has ::: threads block');
|
||||
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Find the thread line
|
||||
const threadLine = lines.find((l) => l.trim().startsWith('::: thread t-'));
|
||||
assert(threadLine !== undefined, 'Found thread line');
|
||||
assert(threadLine.startsWith(' ::: thread'), 'Thread line starts with 2-space indent');
|
||||
|
||||
// Find comment lines
|
||||
const commentLines = lines.filter((l) => l.trim().startsWith('::: comment c-'));
|
||||
assert(commentLines.length === 2, 'Two comment lines in the file');
|
||||
for (const cl of commentLines) {
|
||||
assert(cl.startsWith(' ::: comment'), 'Comment line starts with 4-space indent');
|
||||
// Format: ::: comment c-xxxx "author" "date"
|
||||
assert(/c-[0-9a-f]{8}/.test(cl), 'Comment ID in serialized output');
|
||||
assert(cl.includes('"'), 'Comment line has quoted author/date');
|
||||
}
|
||||
|
||||
// Check reactions block is present
|
||||
assert(content.includes('::: reactions'), 'Reactions block present');
|
||||
assert(content.includes('👍'), 'Reaction emoji in file content');
|
||||
}
|
||||
|
||||
async function testMarkdownItRendering(plugin) {
|
||||
console.log('\n--- markdown-it rendering ---');
|
||||
|
||||
// Load markdown-it from the parser package
|
||||
const mdPath = path.join(__dirname, '..', '..', '..', 'parser', 'node_modules', 'markdown-it', 'dist', 'index.cjs.js');
|
||||
const MarkdownIt = require(mdPath);
|
||||
const md = new MarkdownIt();
|
||||
|
||||
// Register the plugin's containers and highlight rule
|
||||
plugin.markdownSetup(md, {});
|
||||
|
||||
// Test container rendering with the format containers.js expects
|
||||
// Note: the comment container's parseCommentInfo expects "author" "date"
|
||||
// (without a comment ID prefix), which is the format passed as the info string
|
||||
// by createDepthTrackingContainer (everything after "::: comment").
|
||||
// The serialized format from parser.js includes the ID (e.g., c-xxx "Author" "date"),
|
||||
// but containers.js falls back gracefully for unknown formats.
|
||||
|
||||
// Test with the format the serializer actually produces (includes comment ID)
|
||||
const serializedInput = [
|
||||
'::: threads',
|
||||
' ::: thread t-abc12345',
|
||||
' ::: comment c-def67890 "Alice" "2026-01-15"',
|
||||
' This is a comment.',
|
||||
' :::',
|
||||
' :::',
|
||||
':::',
|
||||
].join('\n');
|
||||
|
||||
const html = md.render(serializedInput);
|
||||
|
||||
assert(html.includes('threads-sidebar'), 'Renders threads-sidebar class');
|
||||
assert(html.includes('threads-thread'), 'Renders threads-thread class');
|
||||
assert(html.includes('data-thread-id="t-abc12345"'), 'Renders data-thread-id attribute');
|
||||
assert(html.includes('threads-comment'), 'Renders threads-comment class');
|
||||
assert(html.includes('This is a comment.'), 'Comment body rendered');
|
||||
|
||||
// Test with a simple format the container parser fully understands
|
||||
const simpleInput = [
|
||||
'::: threads',
|
||||
' ::: thread t-simple01',
|
||||
' ::: comment "Bob" "2026-03-01"',
|
||||
' Simple comment.',
|
||||
' :::',
|
||||
' :::',
|
||||
':::',
|
||||
].join('\n');
|
||||
|
||||
const simpleHtml = md.render(simpleInput);
|
||||
assert(simpleHtml.includes('data-author="Bob"'), 'Renders data-author for simple format');
|
||||
assert(simpleHtml.includes('data-date="2026-03-01"'), 'Renders data-date for simple format');
|
||||
|
||||
// Test resolved thread
|
||||
const resolvedInput = [
|
||||
'::: threads',
|
||||
' ::: thread t-res00001 resolved "Bob" "2026-02-01"',
|
||||
' ::: comment "Bob" "2026-02-01"',
|
||||
' Done.',
|
||||
' :::',
|
||||
' :::',
|
||||
':::',
|
||||
].join('\n');
|
||||
|
||||
const resolvedHtml = md.render(resolvedInput);
|
||||
assert(
|
||||
resolvedHtml.includes('threads-thread--resolved'),
|
||||
'Resolved thread has --resolved class'
|
||||
);
|
||||
}
|
||||
|
||||
async function testHighlightRule(plugin) {
|
||||
console.log('\n--- Highlight rule ---');
|
||||
|
||||
const mdPath = path.join(__dirname, '..', '..', '..', 'parser', 'node_modules', 'markdown-it', 'dist', 'index.cjs.js');
|
||||
const MarkdownIt = require(mdPath);
|
||||
const md = new MarkdownIt();
|
||||
|
||||
plugin.markdownSetup(md, {});
|
||||
|
||||
// Basic highlight with thread ID
|
||||
const html1 = md.render('==highlighted text=={t-abc12345}');
|
||||
assert(html1.includes('<mark'), 'Renders <mark> element');
|
||||
assert(html1.includes('threads-highlight'), 'Has threads-highlight class');
|
||||
assert(html1.includes('data-thread-id="t-abc12345"'), 'Has data-thread-id attribute');
|
||||
assert(html1.includes('highlighted text'), 'Contains highlighted text');
|
||||
|
||||
// Highlight without thread ID
|
||||
const html2 = md.render('==just highlighted==');
|
||||
assert(html2.includes('<mark'), 'Renders <mark> without thread ID');
|
||||
assert(html2.includes('threads-highlight'), 'Has threads-highlight class without thread ID');
|
||||
assert(!html2.includes('data-thread-id'), 'No data-thread-id when no ID given');
|
||||
|
||||
// Highlight with surrounding text
|
||||
const html3 = md.render('Before ==marked=={t-001} after.');
|
||||
assert(html3.includes('Before'), 'Text before highlight preserved');
|
||||
assert(html3.includes('after.'), 'Text after highlight preserved');
|
||||
assert(html3.includes('<mark'), 'Mark tag in mixed content');
|
||||
}
|
||||
|
||||
async function testEdgeCases(plugin, ctx) {
|
||||
console.log('\n--- Edge cases ---');
|
||||
|
||||
// Create a second thread to test multiple threads
|
||||
const thread2 = await plugin.actions['threads:add-thread'](
|
||||
{ file: 'test.md', author: 'Charlie', body: 'Another discussion.' },
|
||||
ctx
|
||||
);
|
||||
assert(thread2.id !== undefined, 'Second thread created');
|
||||
|
||||
const threads = await plugin.actions['threads:get-threads'](
|
||||
{ file: 'test.md' },
|
||||
ctx
|
||||
);
|
||||
assert(threads.length === 2, 'Two threads in file after adding second');
|
||||
|
||||
// Delete the second thread to leave file clean
|
||||
await plugin.actions['threads:delete-thread'](
|
||||
{ file: 'test.md', threadId: thread2.id },
|
||||
ctx
|
||||
);
|
||||
|
||||
const afterDelete = await plugin.actions['threads:get-threads'](
|
||||
{ file: 'test.md' },
|
||||
ctx
|
||||
);
|
||||
assert(afterDelete.length === 1, 'Back to one thread after deletion');
|
||||
|
||||
// Test editing a comment
|
||||
const firstCommentId = afterDelete[0].comments[0].id;
|
||||
const edited = await plugin.actions['threads:edit-comment'](
|
||||
{
|
||||
file: 'test.md',
|
||||
threadId: afterDelete[0].id,
|
||||
commentId: firstCommentId,
|
||||
body: 'Updated body.',
|
||||
},
|
||||
ctx
|
||||
);
|
||||
assert(edited.body === 'Updated body.', 'Comment body updated');
|
||||
assert(edited.edited_at !== null, 'edited_at set after edit');
|
||||
|
||||
// Verify edited_at persists in file
|
||||
const content = await ctx.readFile('test.md');
|
||||
assert(content.includes('edited'), 'edited marker in serialized file');
|
||||
}
|
||||
|
||||
async function testFileWithNoExistingThreads(plugin) {
|
||||
console.log('\n--- File with no existing threads ---');
|
||||
|
||||
// Create a separate file with no threads block
|
||||
const noThreadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'threads-no-'));
|
||||
const ctx2 = createMinimalCtx(noThreadDir);
|
||||
fs.writeFileSync(path.join(noThreadDir, 'empty.md'), '# Empty\n\nNothing here.\n');
|
||||
|
||||
const threads = await plugin.actions['threads:get-threads'](
|
||||
{ file: 'empty.md' },
|
||||
ctx2
|
||||
);
|
||||
assert(threads.length === 0, 'No threads in file without threads block');
|
||||
|
||||
// Adding a thread creates the block
|
||||
const newThread = await plugin.actions['threads:add-thread'](
|
||||
{ file: 'empty.md', author: 'Dan', body: 'First thread.' },
|
||||
ctx2
|
||||
);
|
||||
assert(newThread.id.startsWith('t-'), 'Thread created in previously empty file');
|
||||
|
||||
const content = await ctx2.readFile('empty.md');
|
||||
assert(content.includes('# Empty'), 'Original content preserved');
|
||||
assert(content.includes('::: threads'), 'Threads block appended');
|
||||
|
||||
// Cleanup
|
||||
fs.rmSync(noThreadDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function run() {
|
||||
// Setup temp directory and test file
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'threads-integration-'));
|
||||
const testFile = path.join(tempDir, 'test.md');
|
||||
fs.writeFileSync(testFile, '# Hello World\n\nSome content here.\n');
|
||||
|
||||
const ctx = createMinimalCtx(tempDir);
|
||||
|
||||
// 1. Load plugin
|
||||
const plugin = await testPluginLoads();
|
||||
|
||||
// 2. Create a thread
|
||||
const thread = await testCreateThread(plugin, ctx);
|
||||
|
||||
// 3. Add a comment
|
||||
const comment = await testAddComment(plugin, ctx, thread.id);
|
||||
|
||||
// 4. Toggle reactions
|
||||
await testToggleReaction(plugin, ctx, thread.id, comment.id);
|
||||
|
||||
// 5. Read back and verify
|
||||
await testGetThreads(plugin, ctx, thread.id, comment.id);
|
||||
|
||||
// 6. Verify file content
|
||||
await testMarkdownFileContent(ctx);
|
||||
|
||||
// 7. Verify markdown-it rendering
|
||||
await testMarkdownItRendering(plugin);
|
||||
|
||||
// 8. Verify highlight rule
|
||||
await testHighlightRule(plugin);
|
||||
|
||||
// 9. Edge cases
|
||||
await testEdgeCases(plugin, ctx);
|
||||
|
||||
// 10. File with no existing threads
|
||||
await testFileWithNoExistingThreads(plugin);
|
||||
|
||||
console.log(`\nAll integration tests passed. (${passed}/${total})`);
|
||||
}
|
||||
|
||||
run()
|
||||
.catch((e) => {
|
||||
console.error('FAIL:', e.message, e.stack);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
// Cleanup
|
||||
if (tempDir) {
|
||||
try {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore cleanup errors
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
/**
|
||||
* Tests for threads parser - serialize/deserialize ::: threads block
|
||||
*
|
||||
* Run: node packages/plugins/threads/tests/parser.test.js
|
||||
*/
|
||||
|
||||
const {
|
||||
parseThreadsFromContent,
|
||||
serializeThreadsBlock,
|
||||
replaceThreadsBlock,
|
||||
} = require('../src/plugin/parser.js');
|
||||
|
||||
let passed = 0;
|
||||
let total = 0;
|
||||
|
||||
function assert(condition, msg) {
|
||||
total++;
|
||||
if (!condition) {
|
||||
console.error(`FAIL: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
passed++;
|
||||
console.log(` PASS: ${msg}`);
|
||||
}
|
||||
|
||||
function assertDeepEqual(actual, expected, msg) {
|
||||
const a = JSON.stringify(actual);
|
||||
const e = JSON.stringify(expected);
|
||||
assert(a === e, `${msg}\n expected: ${e}\n actual: ${a}`);
|
||||
}
|
||||
|
||||
// ─── Test 1: Parse threads from content with 2 threads, comments, reactions, resolved status ───
|
||||
|
||||
console.log('\nTest 1: Parse full threads block');
|
||||
|
||||
const fullContent = `# My Document
|
||||
|
||||
Some content here.
|
||||
|
||||
::: threads
|
||||
::: thread t-abc123
|
||||
::: comment "alice" "2026-03-07"
|
||||
This is the first comment
|
||||
:::
|
||||
|
||||
::: comment "bob" "2026-03-08" edited "2026-03-09"
|
||||
I agree, let's reword it
|
||||
|
||||
::: reactions
|
||||
- 👍 charlie, dave
|
||||
- 🎉 alice
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
|
||||
::: thread t-def456 resolved "charlie" "2026-03-08"
|
||||
::: comment "charlie" "2026-03-08"
|
||||
Typo in this section
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
|
||||
Some trailing content.
|
||||
`;
|
||||
|
||||
const threads = parseThreadsFromContent(fullContent);
|
||||
|
||||
assert(Array.isArray(threads), 'parseThreadsFromContent returns an array');
|
||||
assert(threads.length === 2, 'parses 2 threads');
|
||||
|
||||
// Thread 1
|
||||
const t1 = threads[0];
|
||||
assert(t1.id === 't-abc123', 'thread 1 id');
|
||||
assert(t1.resolved === false, 'thread 1 not resolved');
|
||||
assert(t1.resolved_by === null, 'thread 1 resolved_by is null');
|
||||
assert(t1.resolved_at === null, 'thread 1 resolved_at is null');
|
||||
assert(t1.comments.length === 2, 'thread 1 has 2 comments');
|
||||
|
||||
// Comment 1 of thread 1
|
||||
const c1 = t1.comments[0];
|
||||
assert(c1.author === 'alice', 'comment 1 author');
|
||||
assert(c1.date === '2026-03-07', 'comment 1 date');
|
||||
assert(c1.edited_at === null, 'comment 1 not edited');
|
||||
assert(c1.body === 'This is the first comment', 'comment 1 body');
|
||||
assert(c1.reactions.length === 0, 'comment 1 no reactions');
|
||||
assert(c1.id.startsWith('c-'), 'comment 1 id starts with c-');
|
||||
assert(c1.id.length === 10, 'comment 1 id is c- + 8 hex chars');
|
||||
assert(c1.thread_id === 't-abc123', 'comment 1 thread_id matches');
|
||||
|
||||
// Comment 2 of thread 1
|
||||
const c2 = t1.comments[1];
|
||||
assert(c2.author === 'bob', 'comment 2 author');
|
||||
assert(c2.date === '2026-03-08', 'comment 2 date');
|
||||
assert(c2.edited_at === '2026-03-09', 'comment 2 edited_at');
|
||||
assert(c2.body === "I agree, let's reword it", 'comment 2 body (no reactions content)');
|
||||
assert(c2.reactions.length === 2, 'comment 2 has 2 reactions');
|
||||
assert(c2.reactions[0].emoji === '👍', 'reaction 1 emoji');
|
||||
assertDeepEqual(c2.reactions[0].authors, ['charlie', 'dave'], 'reaction 1 authors');
|
||||
assert(c2.reactions[1].emoji === '🎉', 'reaction 2 emoji');
|
||||
assertDeepEqual(c2.reactions[1].authors, ['alice'], 'reaction 2 authors');
|
||||
|
||||
// Thread 2
|
||||
const t2 = threads[1];
|
||||
assert(t2.id === 't-def456', 'thread 2 id');
|
||||
assert(t2.resolved === true, 'thread 2 is resolved');
|
||||
assert(t2.resolved_by === 'charlie', 'thread 2 resolved_by');
|
||||
assert(t2.resolved_at === '2026-03-08', 'thread 2 resolved_at');
|
||||
assert(t2.comments.length === 1, 'thread 2 has 1 comment');
|
||||
assert(t2.comments[0].author === 'charlie', 'thread 2 comment author');
|
||||
assert(t2.comments[0].body === 'Typo in this section', 'thread 2 comment body');
|
||||
|
||||
// ─── Test 2: Parse content with no threads block → empty array ───
|
||||
|
||||
console.log('\nTest 2: No threads block');
|
||||
|
||||
const noThreads = `# Just a document\n\nNo threads here.\n`;
|
||||
const emptyResult = parseThreadsFromContent(noThreads);
|
||||
assert(Array.isArray(emptyResult), 'returns array for no threads');
|
||||
assert(emptyResult.length === 0, 'returns empty array');
|
||||
|
||||
// ─── Test 3: Serialize and re-parse (round-trip) ───
|
||||
|
||||
console.log('\nTest 3: Round-trip serialize → parse');
|
||||
|
||||
const threadData = [
|
||||
{
|
||||
id: 't-111111',
|
||||
resolved: false,
|
||||
resolved_by: null,
|
||||
resolved_at: null,
|
||||
comments: [
|
||||
{
|
||||
id: 'c-aabbccdd',
|
||||
thread_id: 't-111111',
|
||||
author: 'eve',
|
||||
date: '2026-01-01',
|
||||
edited_at: null,
|
||||
body: 'Hello world',
|
||||
reactions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 't-222222',
|
||||
resolved: true,
|
||||
resolved_by: 'frank',
|
||||
resolved_at: '2026-02-15',
|
||||
comments: [
|
||||
{
|
||||
id: 'c-11223344',
|
||||
thread_id: 't-222222',
|
||||
author: 'frank',
|
||||
date: '2026-02-10',
|
||||
edited_at: '2026-02-12',
|
||||
body: 'Multi-line\ncontent here',
|
||||
reactions: [
|
||||
{ emoji: '👍', authors: ['eve'] },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const serialized = serializeThreadsBlock(threadData);
|
||||
assert(serialized.startsWith('::: threads\n'), 'serialized starts with ::: threads');
|
||||
assert(serialized.trimEnd().endsWith(':::'), 'serialized ends with :::');
|
||||
|
||||
// Re-parse
|
||||
const reparsed = parseThreadsFromContent(serialized);
|
||||
assert(reparsed.length === 2, 'round-trip: 2 threads');
|
||||
|
||||
assert(reparsed[0].id === 't-111111', 'round-trip: thread 1 id');
|
||||
assert(reparsed[0].resolved === false, 'round-trip: thread 1 not resolved');
|
||||
assert(reparsed[0].comments[0].author === 'eve', 'round-trip: comment author');
|
||||
assert(reparsed[0].comments[0].body === 'Hello world', 'round-trip: comment body');
|
||||
|
||||
assert(reparsed[1].id === 't-222222', 'round-trip: thread 2 id');
|
||||
assert(reparsed[1].resolved === true, 'round-trip: thread 2 resolved');
|
||||
assert(reparsed[1].resolved_by === 'frank', 'round-trip: resolved_by');
|
||||
assert(reparsed[1].resolved_at === '2026-02-15', 'round-trip: resolved_at');
|
||||
assert(reparsed[1].comments[0].author === 'frank', 'round-trip: comment 2 author');
|
||||
assert(reparsed[1].comments[0].edited_at === '2026-02-12', 'round-trip: edited_at');
|
||||
assert(reparsed[1].comments[0].body === 'Multi-line\ncontent here', 'round-trip: multi-line body');
|
||||
assert(reparsed[1].comments[0].reactions.length === 1, 'round-trip: reactions count');
|
||||
assert(reparsed[1].comments[0].reactions[0].emoji === '👍', 'round-trip: reaction emoji');
|
||||
assertDeepEqual(reparsed[1].comments[0].reactions[0].authors, ['eve'], 'round-trip: reaction authors');
|
||||
|
||||
// ─── Test 4: Replace threads block in existing file ───
|
||||
|
||||
console.log('\nTest 4: Replace threads block');
|
||||
|
||||
const existingContent = `# Document
|
||||
|
||||
Some text.
|
||||
|
||||
::: threads
|
||||
::: thread t-old
|
||||
::: comment "olduser" "2025-01-01"
|
||||
Old comment
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
|
||||
Footer text.
|
||||
`;
|
||||
|
||||
const newThreads = [
|
||||
{
|
||||
id: 't-new',
|
||||
resolved: false,
|
||||
resolved_by: null,
|
||||
resolved_at: null,
|
||||
comments: [
|
||||
{
|
||||
id: 'c-newid000',
|
||||
thread_id: 't-new',
|
||||
author: 'newuser',
|
||||
date: '2026-06-01',
|
||||
edited_at: null,
|
||||
body: 'New comment',
|
||||
reactions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const replaced = replaceThreadsBlock(existingContent, newThreads);
|
||||
assert(replaced.includes('# Document'), 'replace preserves header');
|
||||
assert(replaced.includes('Footer text.'), 'replace preserves footer');
|
||||
assert(replaced.includes('t-new'), 'replace includes new thread');
|
||||
assert(!replaced.includes('t-old'), 'replace removes old thread');
|
||||
assert(!replaced.includes('olduser'), 'replace removes old content');
|
||||
|
||||
// Verify the replaced content re-parses correctly
|
||||
const reparsedReplaced = parseThreadsFromContent(replaced);
|
||||
assert(reparsedReplaced.length === 1, 'replaced content has 1 thread');
|
||||
assert(reparsedReplaced[0].id === 't-new', 'replaced thread id correct');
|
||||
|
||||
// ─── Test 5: Add threads block to file that doesn't have one ───
|
||||
|
||||
console.log('\nTest 5: Add threads block to file without one');
|
||||
|
||||
const plainContent = `# My Page
|
||||
|
||||
Just some content.
|
||||
`;
|
||||
|
||||
const withThreads = replaceThreadsBlock(plainContent, newThreads);
|
||||
assert(withThreads.includes('# My Page'), 'add preserves original content');
|
||||
assert(withThreads.includes('::: threads'), 'add includes threads block');
|
||||
assert(withThreads.includes('t-new'), 'add includes thread id');
|
||||
|
||||
const reparsedAdded = parseThreadsFromContent(withThreads);
|
||||
assert(reparsedAdded.length === 1, 'added content has 1 thread');
|
||||
assert(reparsedAdded[0].id === 't-new', 'added thread id correct');
|
||||
|
||||
// ─── Test 6: Replace with empty threads removes the block ───
|
||||
|
||||
console.log('\nTest 6: Replace with empty threads removes block');
|
||||
|
||||
const clearedContent = replaceThreadsBlock(existingContent, []);
|
||||
assert(!clearedContent.includes('::: threads'), 'empty threads removes block');
|
||||
assert(clearedContent.includes('# Document'), 'preserves header after clearing');
|
||||
assert(clearedContent.includes('Footer text.'), 'preserves footer after clearing');
|
||||
|
||||
// ─── Test 7: Comment body with multiple paragraphs ───
|
||||
|
||||
console.log('\nTest 7: Multi-paragraph comment body');
|
||||
|
||||
const multiParaContent = `::: threads
|
||||
::: thread t-mp
|
||||
::: comment "alice" "2026-03-01"
|
||||
First paragraph.
|
||||
|
||||
Second paragraph.
|
||||
|
||||
Third paragraph.
|
||||
:::
|
||||
:::
|
||||
:::
|
||||
`;
|
||||
|
||||
const mpThreads = parseThreadsFromContent(multiParaContent);
|
||||
assert(mpThreads.length === 1, 'multi-para: 1 thread');
|
||||
assert(
|
||||
mpThreads[0].comments[0].body === 'First paragraph.\n\nSecond paragraph.\n\nThird paragraph.',
|
||||
'multi-para: preserves paragraphs in body'
|
||||
);
|
||||
|
||||
// ─── Test 8: Comment IDs are unique ───
|
||||
|
||||
console.log('\nTest 8: Comment IDs are unique');
|
||||
|
||||
const idSet = new Set();
|
||||
for (const thread of threads) {
|
||||
for (const comment of thread.comments) {
|
||||
assert(!idSet.has(comment.id), `comment id ${comment.id} is unique`);
|
||||
idSet.add(comment.id);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Done ───
|
||||
|
||||
console.log(`\n✓ All ${passed}/${total} tests passed.\n`);
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true
|
||||
},
|
||||
"include": ["src/index.ts", "src/plugin/**/*.ts", "src/types.ts"],
|
||||
"exclude": ["src/client/**/*", "node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user