Files
wehub-resource-sync d718c5a372
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:31:57 +08:00

904 lines
25 KiB
JavaScript

import { fromMarkdown } from "https://esm.sh/mdast-util-from-markdown@2.0.0";
import { toMarkdown as mdastUtilToMarkdown } from "https://esm.sh/mdast-util-to-markdown@2.1.2";
import Quill from "https://esm.sh/quill@2.0.3";
/**
* @typedef {Object} QuillAttributes
* @property {boolean} [bold] - Whether the text is bold.
* @property {boolean} [italic] - Whether the text is italic.
* @property {string} [link] - URL if the text is a link.
* @property {number} [header] - Header level (1-3).
* @property {string} [list] - List type ('ordered' or 'bullet').
* @property {boolean} [blockquote] - Whether the text is in a blockquote.
* @property {string} [code-block] - Code language if in a code block.
* @property {string} [alt] - Alt text for images.
*/
/**
* @typedef {Object} QuillOperation
* @property {string|Object} [insert] - Content to insert (string or object with image URL).
* @property {number} [delete] - Number of characters to delete.
* @property {number} [retain] - Number of characters to retain.
* @property {QuillAttributes} [attributes] - Formatting attributes.
*/
/**
* @typedef {Object} QuillDelta
* @property {Array<QuillOperation>} ops - Array of operations in the delta.
*/
/**
* Converts Quill Delta object to a Markdown string using mdast.
* @param {QuillDelta} delta - Quill Delta object (https://quilljs.com/docs/delta/).
* @returns {string} - Markdown representation.
*/
function deltaToMarkdown(delta) {
const mdastTree = deltaToMdast(delta);
const options = {
bullet: "*",
listItemIndent: "one",
handlers: {},
unknownHandler: (node) => {
console.warn(`Unknown node type encountered: ${node.type}`, node);
return false;
},
};
return mdastUtilToMarkdown(mdastTree, options);
}
/**
* Creates a div to replace the textarea and prepares it for Quill.
* @param {HTMLTextAreaElement} textarea - The original textarea.
* @returns {HTMLDivElement} - The div element created for the Quill editor.
*/
function createAndReplaceTextarea(textarea) {
const editorDiv = document.createElement("div");
editorDiv.className = "mb-3";
editorDiv.style.height = "250px";
const label = textarea.closest("label");
if (!label) {
textarea.parentNode.insertBefore(editorDiv, textarea);
} else {
label.parentNode.insertBefore(editorDiv, label.nextSibling);
}
// Hide the original textarea, but keep it focusable for validation
textarea.style = "transform: scale(0); position: absolute; opacity: 0;";
return editorDiv;
}
/**
* Returns the toolbar options array configured for Markdown compatibility.
* @returns {Array<Array<any>>} - Quill toolbar options.
*/
function getMarkdownToolbarOptions() {
return [
[{ header: 1 }, { header: 2 }, { header: 3 }],
["bold", "italic", "code"],
["link", "image", "blockquote", "code-block"],
[{ list: "ordered" }, { list: "bullet" }],
["clean"],
];
}
/**
* Initializes a Quill editor instance on a given div.
* @param {HTMLDivElement} editorDiv - The div element for the editor.
* @param {Array<Array<any>>} toolbarOptions - The toolbar configuration.
* @param {string} initialValue - The initial content for the editor.
* @returns {Quill} - The initialized Quill instance.
*/
function initializeQuillEditor(
editorDiv,
toolbarOptions,
initialValue,
readOnly,
) {
const quill = new Quill(editorDiv, {
theme: "snow",
modules: {
toolbar: toolbarOptions,
},
readOnly: readOnly,
formats: [
"bold",
"italic",
"link",
"header",
"list",
"blockquote",
"code",
"code-block",
"image",
],
});
if (initialValue) {
const delta = markdownToDelta(initialValue);
quill.setContents(delta);
}
return quill;
}
/**
* Converts Markdown string to a Quill Delta object.
* @param {string} markdown - The markdown string to convert.
* @returns {QuillDelta} - Quill Delta representation.
*/
function markdownToDelta(markdown) {
try {
const mdastTree = fromMarkdown(markdown);
return mdastToDelta(mdastTree);
} catch (error) {
console.error("Error parsing markdown:", error);
return { ops: [{ insert: markdown }] };
}
}
/**
* Converts MDAST to Quill Delta.
* @param {MdastNode} tree - The MDAST tree to convert.
* @returns {QuillDelta} - Quill Delta representation.
*/
function mdastToDelta(tree) {
const delta = { ops: [] };
if (!tree || !tree.children) return delta;
for (const node of tree.children) {
traverseMdastNode(node, delta);
}
return delta;
}
/**
* Recursively traverse MDAST nodes and convert to Delta operations.
* @param {MdastNode} node - The MDAST node to process.
* @param {QuillDelta} delta - The Delta object to append operations to.
* @param {QuillAttributes} [attributes={}] - The current attributes to apply.
*/
function traverseMdastNode(node, delta, attributes = {}) {
if (!node) return;
switch (node.type) {
case "root":
for (const child of node.children || []) {
traverseMdastNode(child, delta);
}
break;
case "paragraph": {
for (const child of node.children || []) {
traverseMdastNode(child, delta, attributes);
}
const pLineAttributes = {};
if (attributes.blockquote) {
pLineAttributes.blockquote = true;
}
delta.ops.push({ insert: "\n", attributes: pLineAttributes });
break;
}
case "heading": {
const headingContentAttributes = { ...attributes, header: node.depth };
for (const child of node.children || []) {
traverseMdastNode(child, delta, headingContentAttributes);
}
const headingLineAttributes = { header: node.depth };
if (attributes.blockquote) {
headingLineAttributes.blockquote = true;
}
delta.ops.push({ insert: "\n", attributes: headingLineAttributes });
break;
}
case "text":
delta.ops.push({ insert: node.value || "", attributes });
break;
case "strong":
for (const child of node.children || []) {
traverseMdastNode(child, delta, { ...attributes, bold: true });
}
break;
case "emphasis":
for (const child of node.children || []) {
traverseMdastNode(child, delta, { ...attributes, italic: true });
}
break;
case "link":
for (const child of node.children || []) {
traverseMdastNode(child, delta, { ...attributes, link: node.url });
}
break;
case "image":
delta.ops.push({
insert: { image: node.url },
attributes: { alt: node.alt || "" },
});
break;
case "list":
for (const child of node.children || []) {
traverseMdastNode(child, delta, {
...attributes,
list: node.ordered ? "ordered" : "bullet",
});
}
break;
case "listItem": {
// biome-ignore lint/correctness/noUnusedVariables: object destructuring with a spread
const { list, ...listItemChildrenAttributes } = attributes;
for (const child of node.children || []) {
traverseMdastNode(child, delta, listItemChildrenAttributes);
}
// Attributes for the listItem's newline (e.g., { list: 'bullet', blockquote: true })
// are in `attributes` passed to this `listItem` case.
{
const lastOp = delta.ops[delta.ops.length - 1];
if (lastOp && lastOp.insert === "\n") {
lastOp.attributes = { ...lastOp.attributes, ...attributes };
} else {
delta.ops.push({ insert: "\n", attributes });
}
}
break;
}
case "blockquote":
for (const child of node.children || []) {
traverseMdastNode(child, delta, { ...attributes, blockquote: true });
}
break;
case "code": {
// mdast 'code' is a block
const codeBlockLineFormat = { "code-block": node.lang || true };
if (attributes.blockquote) {
codeBlockLineFormat.blockquote = true;
}
const textInCodeAttributes = {};
if (attributes.blockquote) {
// Text lines also get blockquote if active
textInCodeAttributes.blockquote = true;
}
const lines = (node.value || "").split("\n");
for (const lineText of lines) {
delta.ops.push({ insert: lineText, attributes: textInCodeAttributes });
delta.ops.push({ insert: "\n", attributes: codeBlockLineFormat });
}
break;
}
case "inlineCode":
delta.ops.push({
insert: node.value || "",
attributes: { ...attributes, code: true },
});
break;
default:
if (node.children) {
for (const child of node.children) {
traverseMdastNode(child, delta, attributes);
}
} else if (node.value) {
delta.ops.push({ insert: node.value, attributes });
}
}
}
/**
* Attaches a submit event listener to the form to update the hidden textarea.
* @param {HTMLFormElement|null} form - The form containing the editor.
* @param {HTMLTextAreaElement} textarea - The original (hidden) textarea.
* @param {Quill} quill - The Quill editor instance.
* @returns {void}
*/
function updateTextareaOnSubmit(form, textarea, quill) {
if (!form) {
console.warn(
"Textarea not inside a form, submission handling skipped for:",
textarea.name || textarea.id,
);
return;
}
form.addEventListener("submit", (event) => {
const delta = quill.getContents();
const markdownContent = deltaToMarkdown(delta);
textarea.value = markdownContent;
console.log(
`${textarea.name}:\n${markdownContent}\ntransformed from delta:\n${JSON.stringify(delta, null, 2)}`,
);
if (textarea.required && !markdownContent) {
textarea.setCustomValidity(`${textarea.name} cannot be empty`);
quill.once("text-change", (delta) => {
textarea.value = deltaToMarkdown(delta);
textarea.setCustomValidity("");
});
quill.focus();
event.preventDefault();
}
});
}
/**
* Loads the Quill CSS stylesheet.
* @returns {void}
*/
function loadQuillStylesheet() {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://esm.sh/quill@2.0.3/dist/quill.snow.css";
document.head.appendChild(link);
}
/**
* Handles errors during editor initialization.
* @param {HTMLTextAreaElement} textarea - The textarea that failed initialization.
* @param {Error} error - The error that occurred.
* @returns {void}
*/
function handleEditorInitError(textarea, error) {
console.error("Failed to initialize Quill for textarea:", textarea, error);
textarea.style.display = "";
const errorMsg = document.createElement("p");
errorMsg.textContent = "Failed to load rich text editor.";
errorMsg.style.color = "red";
textarea.parentNode.insertBefore(errorMsg, textarea.nextSibling);
}
/**
* Sets up a single editor for a textarea.
* @param {HTMLTextAreaElement} textarea - The textarea to replace with an editor.
* @param {Array<Array<any>>} toolbarOptions - The toolbar configuration.
* @returns {boolean} - Whether the setup was successful.
*/
function setupSingleEditor(textarea, toolbarOptions) {
if (textarea.dataset.quillInitialized === "true") {
return false;
}
try {
const initialValue = textarea.value;
const readOnly = textarea.readOnly || textarea.disabled;
const form = textarea.closest("form");
const editorDiv = createAndReplaceTextarea(textarea);
const quill = initializeQuillEditor(
editorDiv,
toolbarOptions,
initialValue,
readOnly,
);
updateTextareaOnSubmit(form, textarea, quill);
textarea.dataset.quillInitialized = "true";
return true;
} catch (error) {
handleEditorInitError(textarea, error);
return false;
}
}
/**
* Initializes Quill editors for all textareas in the document.
* @returns {void}
*/
function initializeEditors() {
loadQuillStylesheet();
const textareas = document.getElementsByTagName("textarea");
if (textareas.length === 0) {
return;
}
const toolbarOptions = getMarkdownToolbarOptions();
let initializedCount = 0;
for (const textarea of textareas) {
if (setupSingleEditor(textarea, toolbarOptions)) {
initializedCount++;
}
}
if (initializedCount > 0) {
console.log(
`Successfully initialized Quill for ${initializedCount} textareas.`,
);
}
}
// MDAST conversion functions
/**
* @typedef {Object} MdastNode
* @property {string} type - The type of the node.
* @property {Array<MdastNode>} [children] - Child nodes.
* @property {string} [value] - Text value for text nodes.
* @property {string} [url] - URL for link and image nodes.
* @property {string} [title] - Title for image nodes.
* @property {string} [alt] - Alt text for image nodes.
* @property {number} [depth] - Depth for heading nodes.
* @property {boolean} [ordered] - Whether the list is ordered.
* @property {boolean} [spread] - Whether the list is spread.
* @property {string} [lang] - Language for code blocks.
*/
/**
* Converts a Quill Delta to a MDAST (Markdown Abstract Syntax Tree).
* @param {QuillDelta} delta - The Quill Delta to convert.
* @returns {MdastNode} - The root MDAST node.
*/
function deltaToMdast(delta) {
const mdast = createRootNode();
/** @type {MdastNode|null} */
let currentParagraph = null;
/** @type {MdastNode|null} */
let currentList = null;
let textBuffer = "";
for (const op of delta.ops) {
if (isImageInsert(op)) {
if (!currentParagraph) {
currentParagraph = createParagraphNode();
}
currentParagraph.children.push(createImageNode(op));
}
if (typeof op.insert !== "string") continue;
const text = op.insert;
const attributes = op.attributes || {};
// Handle newlines within text content
if (text.includes("\n") && text !== "\n") {
const lines = text.split("\n");
// Process all lines except the last one as complete lines
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
if (line.length > 0) {
// Add text to current paragraph
if (!currentParagraph) {
currentParagraph = createParagraphNode();
}
const nodes = createTextNodes(line, attributes);
currentParagraph.children.push(...nodes);
textBuffer = line;
}
// Process line break
currentList = processLineBreak(
mdast,
currentParagraph,
attributes,
textBuffer,
currentList,
);
currentParagraph = null;
textBuffer = "";
}
// Add the last line to the buffer without processing the line break yet
const lastLine = lines[lines.length - 1];
if (lastLine.length > 0) {
if (!currentParagraph) {
currentParagraph = createParagraphNode();
}
const nodes = createTextNodes(lastLine, attributes);
currentParagraph.children.push(...nodes);
textBuffer = lastLine;
}
continue;
}
if (text === "\n") {
currentList = processLineBreak(
mdast,
currentParagraph,
attributes,
textBuffer,
currentList,
);
// Reset paragraph and buffer after processing line break
currentParagraph = null;
textBuffer = "";
continue;
}
// Process regular text
const nodes = createTextNodes(text, attributes);
if (!currentParagraph) {
currentParagraph = createParagraphNode();
}
textBuffer += text;
currentParagraph.children.push(...nodes);
}
if (currentParagraph) {
mdast.children.push(currentParagraph);
}
return mdast;
}
/**
* Creates a root MDAST node.
* @returns {MdastNode} - The root node.
*/
function createRootNode() {
return {
type: "root",
children: [],
};
}
/**
* Creates a paragraph MDAST node.
* @returns {MdastNode} - The paragraph node.
*/
function createParagraphNode() {
return {
type: "paragraph",
children: [],
};
}
/**
* Checks if an operation is an image insertion.
* @param {Object} op - The operation to check.
* @returns {boolean} - Whether the operation is an image insertion.
*/
function isImageInsert(op) {
return typeof op.insert === "object" && op.insert.image;
}
/**
* Creates an image MDAST node.
* @param {Object} op - The operation containing the image.
* @returns {MdastNode} - The image node.
*/
function createImageNode(op) {
return {
type: "image",
url: op.insert.image,
title: op.attributes?.alt || "",
alt: op.attributes?.alt || "",
};
}
/**
* Creates a text MDAST node with optional formatting.
* @param {string} text - The text content.
* @param {Object} attributes - The formatting attributes.
* @returns {MdastNode[]} - The formatted text nodes.
*/
function createTextNodes(text, attributes) {
let nodes = text.split("\n").flatMap((value, i) => [
...(i > 0 ? [{ type: "break" }] : []),
{
type: "text",
value,
},
]);
if (attributes.bold) {
nodes = [wrapNodesWith(nodes, "strong")];
}
if (attributes.italic) {
nodes = [wrapNodesWith(nodes, "emphasis")];
}
if (attributes.link) {
nodes = [{ ...wrapNodesWith(nodes, "link"), url: attributes.link }];
}
return nodes;
}
/**
* Wraps a node with a formatting container.
* @param {MdastNode[]} children - The node to wrap.
* @param {string} type - The type of container.
* @returns {MdastNode} - The wrapped node.
*/
function wrapNodesWith(children, type) {
return {
type: type,
children,
};
}
/**
* Processes a line break in the Delta.
* @param {MdastNode} mdast - The root MDAST node.
* @param {MdastNode|null} currentParagraph - The current paragraph being built.
* @param {Object} attributes - The attributes for the line.
* @param {string} textBuffer - The text buffer for the current line.
* @param {MdastNode|null} currentList - The current list being built.
* @returns {MdastNode|null} - The updated current list.
*/
function processLineBreak(
mdast,
currentParagraph,
attributes,
textBuffer,
currentList,
) {
if (!currentParagraph) {
return handleEmptyLineWithAttributes(mdast, attributes, currentList);
}
if (attributes.header) {
processHeaderLineBreak(mdast, textBuffer, attributes);
return null;
}
if (attributes["code-block"]) {
processCodeBlockLineBreak(mdast, textBuffer, attributes);
return currentList;
}
if (attributes.list) {
return processListLineBreak(
mdast,
currentParagraph,
attributes,
currentList,
);
}
if (attributes.blockquote) {
processBlockquoteLineBreak(mdast, currentParagraph);
return currentList;
}
// Default case: regular paragraph
mdast.children.push(currentParagraph);
return null;
}
/**
* Handles an empty line with special attributes.
* @param {MdastNode} mdast - The root MDAST node.
* @param {Object} attributes - The attributes for the line.
* @param {MdastNode|null} currentList - The current list being built.
* @returns {MdastNode|null} - The updated current list.
*/
function handleEmptyLineWithAttributes(mdast, attributes, currentList) {
if (attributes["code-block"]) {
mdast.children.push(createEmptyCodeBlock(attributes));
return currentList;
}
if (attributes.list) {
const list = ensureList(mdast, attributes, currentList);
list.children.push(createEmptyListItem());
return list;
}
if (attributes.blockquote) {
mdast.children.push(createEmptyBlockquote());
return currentList;
}
return null;
}
/**
* Creates an empty code block MDAST node.
* @param {Object} attributes - The attributes for the code block.
* @returns {MdastNode} - The code block node.
*/
function createEmptyCodeBlock(attributes) {
return {
type: "code",
value: "",
lang:
attributes["code-block"] === "plain" ? null : attributes["code-block"],
};
}
/**
* Creates an empty list item MDAST node.
* @returns {MdastNode} - The list item node.
*/
function createEmptyListItem() {
return {
type: "listItem",
spread: false,
children: [{ type: "paragraph", children: [] }],
};
}
/**
* Creates an empty blockquote MDAST node.
* @returns {MdastNode} - The blockquote node.
*/
function createEmptyBlockquote() {
return {
type: "blockquote",
children: [{ type: "paragraph", children: [] }],
};
}
/**
* Processes a header line break.
* @param {MdastNode} mdast - The root MDAST node.
* @param {string} textBuffer - The text buffer for the current line.
* @param {Object} attributes - The attributes for the line.
* @returns {void}
*/
function processHeaderLineBreak(mdast, textBuffer, attributes) {
const lines = textBuffer.split("\n");
if (lines.length > 1) {
const lastLine = lines.pop();
const previousLines = lines.join("\n");
if (previousLines) {
mdast.children.push({
type: "paragraph",
children: [{ type: "text", value: previousLines }],
});
}
mdast.children.push({
type: "heading",
depth: attributes.header,
children: [{ type: "text", value: lastLine }],
});
} else {
mdast.children.push({
type: "heading",
depth: attributes.header,
children: [{ type: "text", value: textBuffer }],
});
}
}
/**
* Processes a code block line break.
* @param {MdastNode} mdast - The root MDAST node.
* @param {string} textBuffer - The text buffer for the current line.
* @param {Object} attributes - The attributes for the line.
* @returns {void}
*/
function processCodeBlockLineBreak(mdast, textBuffer, attributes) {
const lang =
attributes["code-block"] === "plain" ? null : attributes["code-block"];
// Find the last code block with the same language
let lastCodeBlock = null;
for (let i = mdast.children.length - 1; i >= 0; i--) {
const child = mdast.children[i];
if (child.type === "code" && child.lang === lang) {
lastCodeBlock = child;
break;
}
}
if (lastCodeBlock) {
// Append to existing code block with same language
lastCodeBlock.value += `\n${textBuffer}`;
} else {
// Create new code block
mdast.children.push({
type: "code",
value: textBuffer,
lang,
});
}
}
/**
* Ensures a list exists in the MDAST.
* @param {MdastNode} mdast - The root MDAST node.
* @param {Object} attributes - The attributes for the line.
* @param {MdastNode|null} currentList - The current list being built.
* @returns {MdastNode} - The list node.
*/
function ensureList(mdast, attributes, currentList) {
const isOrderedList = attributes.list === "ordered";
// If there's no current list or the list type doesn't match
if (!currentList || currentList.ordered !== isOrderedList) {
// Check if the last child is a list of the correct type
const lastChild = mdast.children[mdast.children.length - 1];
if (
lastChild &&
lastChild.type === "list" &&
lastChild.ordered === isOrderedList
) {
// Use the last list if it matches the type
return lastChild;
}
// Create a new list
const newList = {
type: "list",
ordered: isOrderedList,
spread: false,
children: [],
};
mdast.children.push(newList);
return newList;
}
return currentList;
}
/**
* Processes a list line break.
* @param {MdastNode} mdast - The root MDAST node.
* @param {MdastNode} currentParagraph - The current paragraph being built.
* @param {Object} attributes - The attributes for the line.
* @param {MdastNode|null} currentList - The current list being built.
* @returns {MdastNode} - The updated list node.
*/
function processListLineBreak(
mdast,
currentParagraph,
attributes,
currentList,
) {
const list = ensureList(mdast, attributes, currentList);
// Check if this list item already exists to avoid duplication
const paragraphContent = JSON.stringify(currentParagraph.children);
const isDuplicate = list.children.some(
(item) =>
item.children?.length === 1 &&
JSON.stringify(item.children[0].children) === paragraphContent,
);
if (!isDuplicate) {
const listItem = {
type: "listItem",
spread: false,
children: [currentParagraph],
};
list.children.push(listItem);
}
return list;
}
/**
* Processes a blockquote line break.
* @param {MdastNode} mdast - The root MDAST node.
* @param {MdastNode} currentParagraph - The current paragraph being built.
* @returns {void}
*/
function processBlockquoteLineBreak(mdast, currentParagraph) {
// Look for an existing blockquote with identical content to avoid duplication
const paragraphContent = JSON.stringify(currentParagraph.children);
const existingBlockquote = mdast.children.find(
(child) =>
child.type === "blockquote" &&
child.children?.length === 1 &&
JSON.stringify(child.children[0].children) === paragraphContent,
);
if (!existingBlockquote) {
mdast.children.push({
type: "blockquote",
children: [currentParagraph],
});
}
}
// Main execution
document.addEventListener("DOMContentLoaded", initializeEditors);