diff --git a/plug-api/lib/json.test.ts b/plug-api/lib/json.test.ts new file mode 100644 index 00000000..9d365558 --- /dev/null +++ b/plug-api/lib/json.test.ts @@ -0,0 +1,21 @@ +import { assertEquals } from "$lib/test_deps.ts"; +import { deepEqual, deepObjectMerge, expandPropertyNames } from "./json.ts"; + +Deno.test("utils", () => { + assertEquals(deepEqual({ a: 1 }, { a: 1 }), true); + assertEquals(deepObjectMerge({ a: 1 }, { a: 2 }), { a: 2 }); + assertEquals( + deepObjectMerge({ list: [1, 2, 3] }, { list: [4, 5, 6] }), + { list: [1, 2, 3, 4, 5, 6] }, + ); + assertEquals(deepObjectMerge({ a: { b: 1 } }, { a: { c: 2 } }), { + a: { b: 1, c: 2 }, + }); + assertEquals(expandPropertyNames({ "a.b": 1 }), { a: { b: 1 } }); + assertEquals(expandPropertyNames({ a: { "a.b": 1 } }), { + a: { a: { b: 1 } }, + }); + assertEquals(expandPropertyNames({ a: [{ "a.b": 1 }] }), { + a: [{ a: { b: 1 } }], + }); +}); diff --git a/plug-api/lib/json.ts b/plug-api/lib/json.ts new file mode 100644 index 00000000..7bb56758 --- /dev/null +++ b/plug-api/lib/json.ts @@ -0,0 +1,86 @@ +// Compares two objects deeply +export function deepEqual(a: any, b: any): boolean { + if (a === b) { + return true; + } + if (typeof a !== typeof b) { + return false; + } + if (typeof a === "object") { + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) { + return false; + } + } + return true; + } else { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + if (aKeys.length !== bKeys.length) { + return false; + } + for (const key of aKeys) { + if (!deepEqual(a[key], b[key])) { + return false; + } + } + return true; + } + } + return false; +} + +// Expands property names in an object containing a .-separated path +export function expandPropertyNames(a: any): any { + if (!a) { + return a; + } + if (typeof a !== "object") { + return a; + } + if (Array.isArray(a)) { + return a.map(expandPropertyNames); + } + const expanded: any = {}; + for (const key of Object.keys(a)) { + const parts = key.split("."); + let target = expanded; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!target[part]) { + target[part] = {}; + } + target = target[part]; + } + target[parts[parts.length - 1]] = expandPropertyNames(a[key]); + } + return expanded; +} + +export function deepObjectMerge(a: any, b: any): any { + if (typeof a !== typeof b) { + return b; + } + if (typeof a === "object") { + if (Array.isArray(a) && Array.isArray(b)) { + return [...a, ...b]; + } else { + const aKeys = Object.keys(a); + const bKeys = Object.keys(b); + const merged = { ...a }; + for (const key of bKeys) { + if (aKeys.includes(key)) { + merged[key] = deepObjectMerge(a[key], b[key]); + } else { + merged[key] = b[key]; + } + } + return merged; + } + } + return b; +} diff --git a/plug-api/lib/tree.test.ts b/plug-api/lib/tree.test.ts new file mode 100644 index 00000000..27026c82 --- /dev/null +++ b/plug-api/lib/tree.test.ts @@ -0,0 +1,84 @@ +// import { parse } from "./parse_tree.ts"; +import { + addParentPointers, + collectNodesMatching, + findParentMatching, + nodeAtPos, + parseTreeToAST, + removeParentPointers, + renderToText, + replaceNodesMatching, +} from "$sb/lib/tree.ts"; +import { assertEquals, assertNotEquals } from "$lib/test_deps.ts"; +import { parse } from "$common/markdown_parser/parse_tree.ts"; +import { extendedMarkdownLanguage } from "$common/markdown_parser/parser.ts"; + +const mdTest1 = ` +# Heading +## Sub _heading_ cool + +Hello, this is some **bold** text and *italic*. And [a link](http://zef.me). + +%% My comment here +%% And second line + +And an @mention + +http://zef.plus + +- This is a list [[PageLink]] +- With another item +- TODOs: + - [ ] A task that's not yet done + - [x] Hello +- And a _third_ one [[Wiki Page]] yo +`; + +const mdTest2 = ` +Hello + +* Item 1 +* + +Sup`; + +const mdTest3 = ` +\`\`\`yaml +name: something +\`\`\` +`; + +Deno.test("Test parsing", () => { + const mdTree = parse(extendedMarkdownLanguage, mdTest1); + addParentPointers(mdTree); + // console.log(JSON.stringify(mdTree, null, 2)); + const wikiLink = nodeAtPos(mdTree, mdTest1.indexOf("Wiki Page"))!; + assertEquals(wikiLink.type, "WikiLinkPage"); + assertNotEquals( + findParentMatching(wikiLink, (n) => n.type === "BulletList"), + null, + ); + + const allTodos = collectNodesMatching(mdTree, (n) => n.type === "Task"); + assertEquals(allTodos.length, 2); + + // Render back into markdown should be equivalent + assertEquals(renderToText(mdTree), mdTest1); + + removeParentPointers(mdTree); + replaceNodesMatching(mdTree, (n) => { + if (n.type === "Task") { + return { + type: "Tosk", + }; + } + }); + // console.log(JSON.stringify(mdTree, null, 2)); + let mdTree3 = parse(extendedMarkdownLanguage, mdTest3); + // console.log(JSON.stringify(mdTree3, null, 2)); +}); + +Deno.test("AST functions", () => { + const mdTree = parse(extendedMarkdownLanguage, mdTest1); + console.log(JSON.stringify(parseTreeToAST(mdTree), null, 2)); +}); diff --git a/plug-api/lib/tree.ts b/plug-api/lib/tree.ts new file mode 100644 index 00000000..5228d177 --- /dev/null +++ b/plug-api/lib/tree.ts @@ -0,0 +1,237 @@ +export type ParseTree = { + type?: string; // undefined === text node + from?: number; + to?: number; + text?: string; + children?: ParseTree[]; + // Only present after running addParentPointers + parent?: ParseTree; +}; + +export type AST = [string, ...AST[]] | string; + +export function addParentPointers(tree: ParseTree) { + if (!tree.children) { + return; + } + for (const child of tree.children) { + if (child.parent) { + // Already added parent pointers before + return; + } + child.parent = tree; + addParentPointers(child); + } +} + +export function removeParentPointers(tree: ParseTree) { + delete tree.parent; + if (!tree.children) { + return; + } + for (const child of tree.children) { + removeParentPointers(child); + } +} + +export function findParentMatching( + tree: ParseTree, + matchFn: (tree: ParseTree) => boolean, +): ParseTree | null { + let node = tree.parent; + while (node) { + if (matchFn(node)) { + return node; + } + node = node.parent; + } + return null; +} + +export function collectNodesOfType( + tree: ParseTree, + nodeType: string, +): ParseTree[] { + return collectNodesMatching(tree, (n) => n.type === nodeType); +} + +export function collectNodesMatching( + tree: ParseTree, + matchFn: (tree: ParseTree) => boolean, +): ParseTree[] { + if (matchFn(tree)) { + return [tree]; + } + let results: ParseTree[] = []; + if (tree.children) { + for (const child of tree.children) { + results = [...results, ...collectNodesMatching(child, matchFn)]; + } + } + return results; +} + +export async function collectNodesMatchingAsync( + tree: ParseTree, + matchFn: (tree: ParseTree) => Promise, +): Promise { + if (await matchFn(tree)) { + return [tree]; + } + let results: ParseTree[] = []; + if (tree.children) { + for (const child of tree.children) { + results = [ + ...results, + ...await collectNodesMatchingAsync(child, matchFn), + ]; + } + } + return results; +} + +// return value: returning undefined = not matched, continue, null = delete, new node = replace +export function replaceNodesMatching( + tree: ParseTree, + substituteFn: (tree: ParseTree) => ParseTree | null | undefined, +) { + if (tree.children) { + const children = tree.children.slice(); + for (const child of children) { + const subst = substituteFn(child); + if (subst !== undefined) { + const pos = tree.children.indexOf(child); + if (subst) { + tree.children.splice(pos, 1, subst); + } else { + // null = delete + tree.children.splice(pos, 1); + } + } else { + replaceNodesMatching(child, substituteFn); + } + } + } +} + +export async function replaceNodesMatchingAsync( + tree: ParseTree, + substituteFn: (tree: ParseTree) => Promise, +) { + if (tree.children) { + const children = tree.children.slice(); + for (const child of children) { + const subst = await substituteFn(child); + if (subst !== undefined) { + const pos = tree.children.indexOf(child); + if (subst) { + tree.children.splice(pos, 1, subst); + } else { + // null = delete + tree.children.splice(pos, 1); + } + } else { + await replaceNodesMatchingAsync(child, substituteFn); + } + } + } +} + +export function findNodeMatching( + tree: ParseTree, + matchFn: (tree: ParseTree) => boolean, +): ParseTree | null { + return collectNodesMatching(tree, matchFn)[0]; +} + +export function findNodeOfType( + tree: ParseTree, + nodeType: string, +): ParseTree | null { + return collectNodesMatching(tree, (n) => n.type === nodeType)[0]; +} + +export function traverseTree( + tree: ParseTree, + // Return value = should stop traversal? + matchFn: (tree: ParseTree) => boolean, +): void { + // Do a collect, but ignore the result + collectNodesMatching(tree, matchFn); +} + +export async function traverseTreeAsync( + tree: ParseTree, + // Return value = should stop traversal? + matchFn: (tree: ParseTree) => Promise, +): Promise { + // Do a collect, but ignore the result + await collectNodesMatchingAsync(tree, matchFn); +} + +// Finds non-text node at position +export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null { + if (pos < tree.from! || pos >= tree.to!) { + return null; + } + if (!tree.children) { + return tree; + } + for (const child of tree.children) { + const n = nodeAtPos(child, pos); + if (n && n.text !== undefined) { + // Got a text node, let's return its parent + return tree; + } else if (n) { + // Got it + return n; + } + } + return null; +} + +// Turn ParseTree back into text +export function renderToText(tree?: ParseTree): string { + if (!tree) { + return ""; + } + const pieces: string[] = []; + if (tree.text !== undefined) { + return tree.text; + } + for (const child of tree.children!) { + pieces.push(renderToText(child)); + } + return pieces.join(""); +} + +export function cloneTree(tree: ParseTree): ParseTree { + const newTree = { ...tree }; + if (tree.children) { + newTree.children = tree.children.map(cloneTree); + } + delete newTree.parent; + return newTree; +} + +export function parseTreeToAST(tree: ParseTree, omitTrimmable = true): AST { + const parseErrorNodes = collectNodesOfType(tree, "⚠"); + if (parseErrorNodes.length > 0) { + throw new Error( + `Parse error in: ${renderToText(tree)}`, + ); + } + if (tree.text !== undefined) { + return tree.text; + } + const ast: AST = [tree.type!]; + for (const node of tree.children!) { + if (node.type && !node.type.endsWith("Mark")) { + ast.push(parseTreeToAST(node, omitTrimmable)); + } + if (node.text && (omitTrimmable && node.text.trim() || !omitTrimmable)) { + ast.push(node.text); + } + } + return ast; +} diff --git a/plug-api/types.ts b/plug-api/types.ts new file mode 100644 index 00000000..6e73a0bf --- /dev/null +++ b/plug-api/types.ts @@ -0,0 +1,262 @@ +import type { ParseTree } from "./lib/tree.ts"; +import type { TextChange } from "../web/change.ts"; + +export type FileMeta = { + name: string; + created: number; + lastModified: number; + contentType: string; + size: number; + perm: "ro" | "rw"; + noSync?: boolean; +}; + +export type PageMeta = ObjectValue< + { + name: string; + created: string; // indexing it as a string + lastModified: string; // indexing it as a string + lastOpened?: number; + perm: "ro" | "rw"; + } & Record +>; + +export type AttachmentMeta = { + name: string; + contentType: string; + created: number; + lastModified: number; + size: number; + perm: "ro" | "rw"; +}; + +export type SyscallMeta = { + name: string; + requiredPermissions: string[]; + argCount: number; +}; + +// Message Queue related types +export type MQMessage = { + id: string; + queue: string; + body: any; + retries?: number; +}; + +export type MQStats = { + queued: number; + processing: number; + dlq: number; +}; + +export type MQSubscribeOptions = { + batchSize?: number; + pollInterval?: number; +}; + +// Key-Value Store related types +export type KvKey = string[]; + +export type KV = { + key: KvKey; + value: T; +}; + +export type OrderBy = { + expr: QueryExpression; + desc: boolean; +}; + +export type Select = { + name: string; + expr?: QueryExpression; +}; + +export type Query = { + querySource?: string; + filter?: QueryExpression; + orderBy?: OrderBy[]; + select?: Select[]; + limit?: QueryExpression; + render?: string; + renderAll?: boolean; + distinct?: boolean; +}; + +export type KvQuery = Omit & { + prefix?: KvKey; +}; + +export type QueryExpression = + | ["and", QueryExpression, QueryExpression] + | ["or", QueryExpression, QueryExpression] + | ["=", QueryExpression, QueryExpression] + | ["!=", QueryExpression, QueryExpression] + | ["=~", QueryExpression, QueryExpression] + | ["!=~", QueryExpression, QueryExpression] + | ["<", QueryExpression, QueryExpression] + | ["<=", QueryExpression, QueryExpression] + | [">", QueryExpression, QueryExpression] + | [">=", QueryExpression, QueryExpression] + | ["in", QueryExpression, QueryExpression] + | ["attr"] // . + | ["attr", string] // name + | ["attr", QueryExpression, string] // something.name + | ["global", string] // @name + | ["number", number] + | ["string", string] + | ["boolean", boolean] + | ["null"] + | ["not", QueryExpression] + | ["array", QueryExpression[]] + | ["object", [string, QueryExpression][]] + | ["regexp", string, string] // regex, modifier + | ["pageref", string] + | ["-", QueryExpression] + | ["+", QueryExpression, QueryExpression] + | ["-", QueryExpression, QueryExpression] + | ["*", QueryExpression, QueryExpression] + | ["%", QueryExpression, QueryExpression] + | ["/", QueryExpression, QueryExpression] + | ["?", QueryExpression, QueryExpression, QueryExpression] + | ["query", Query] + | ["call", string, QueryExpression[]]; + +export type FunctionMap = Record< + string, + (...args: any[]) => any +>; + +/** + * An ObjectValue that can be indexed by the `index` plug, needs to have a minimum of + * of two fields: + * - ref: a unique reference (id) for the object, ideally a page reference + * - tags: a list of tags that the object belongs to + */ +export type ObjectValue = { + ref: string; + tag: string; // main tag + tags?: string[]; + itags?: string[]; // implicit or inherited tags (inherited from the page for instance) +} & T; + +export type ObjectQuery = Omit; + +// Code widget stuff +export type CodeWidgetCallback = ( + bodyText: string, + pageName: string, +) => Promise; + +export type CodeWidgetContent = { + html?: string; + markdown?: string; + script?: string; + buttons?: CodeWidgetButton[]; +}; + +export type CodeWidgetButton = { + widgetTarget?: boolean; + description: string; + svg: string; + invokeFunction: string; +}; + +export type LintDiagnostic = { + from: number; + to: number; + severity: "error" | "warning" | "info" | "hint"; + message: string; +}; + +export type UploadFile = { + name: string; + contentType: string; + content: Uint8Array; +}; + +export type AppEvent = + | "page:click" + | "editor:complete" + | "minieditor:complete" + | "slash:complete" + | "editor:lint" + | "page:load" + | "editor:init" + | "editor:pageLoaded" // args: pageName, previousPage, isSynced + | "editor:pageReloaded" + | "editor:pageSaved" + | "editor:modeswitch" + | "plugs:loaded" + | "editor:pageModified"; + +export type QueryProviderEvent = { + query: Query; + variables?: Record; +}; + +export type ClickEvent = { + page: string; + pos: number; + metaKey: boolean; + ctrlKey: boolean; + altKey: boolean; +}; + +export type IndexEvent = { + name: string; + text: string; +}; + +export type IndexTreeEvent = { + name: string; + tree: ParseTree; +}; + +export type PublishEvent = { + uri?: string; + // Page name + name: string; +}; + +export type LintEvent = { + name: string; + tree: ParseTree; +}; + +export type CompleteEvent = { + pageName: string; + linePrefix: string; + pos: number; + parentNodes: string[]; +}; + +export type SlashCompletionOption = { + label: string; + detail?: string; + invoke: string; + order?: number; +} & Record; + +export type SlashCompletions = { + // Ignore this one, only for compatibility with regular completions + from?: number; + // The actual completions + options: SlashCompletionOption[]; +}; + +export type WidgetContent = { + html?: string; + script?: string; + markdown?: string; + url?: string; + height?: number; + width?: number; +}; + +/** PageModifiedEvent payload for "editor:pageModified". Fired when the document text changes + */ +export type PageModifiedEvent = { + changes: TextChange[]; +};