chore: import upstream snapshot with attribution
docmd CI verification / verify (push) Failing after 0s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:31:55 +08:00
commit 6db8fca185
437 changed files with 68762 additions and 0 deletions
+21
View File
@@ -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.
+19
View File
@@ -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
+31
View File
@@ -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
View File
@@ -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>;
};
+215
View File
@@ -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);
});
});
+326
View File
@@ -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"]
}
+44
View File
@@ -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"
}
+44
View File
@@ -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": "अधिक"
}
+44
View File
@@ -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": "명 더"
}
+44
View File
@@ -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": "更多"
}
+78
View File
@@ -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"]
}
+90
View File
@@ -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> &middot; ${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;
}
+83
View File
@@ -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('&lt;span') || html11.includes('&amp;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`);
+20
View File
@@ -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"]
}