Merge branch 'main' into experiment/editContext
This commit is contained in:
commit
213a1f7ae7
26 changed files with 696 additions and 231 deletions
16
src/components/error/error.module.css
Normal file
16
src/components/error/error.module.css
Normal file
|
@ -0,0 +1,16 @@
|
|||
.error {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
background: repeating-linear-gradient(-45deg,
|
||||
color(from var(--fail) xyz x y z / .05),
|
||||
color(from var(--fail) xyz x y z / .05) 10px,
|
||||
color(from var(--fail) xyz x y z / .25) 10px,
|
||||
color(from var(--fail) xyz x y z / .25) 12px,
|
||||
color(from var(--fail) xyz x y z / .05) 12px);
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--fail);
|
||||
border-radius: var(--radii-m);
|
||||
|
||||
margin: var(--padding-l);
|
||||
}
|
16
src/components/error/error.tsx
Normal file
16
src/components/error/error.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { Component, Show } from "solid-js";
|
||||
import css from './error.module.css';
|
||||
|
||||
export const ErrorComp: Component<{ error: Error }> = (props) => {
|
||||
return <div class={css.error}>
|
||||
<b>{props.error.message}</b>
|
||||
|
||||
<Show when={props.error.cause}>{
|
||||
cause => <>{cause().description}</>
|
||||
}</Show>
|
||||
|
||||
{props.error.stack}
|
||||
|
||||
<a href="/">Return to start</a>
|
||||
</div>;
|
||||
};
|
1
src/components/error/index.tsx
Normal file
1
src/components/error/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { ErrorComp } from './error';
|
|
@ -1,15 +1,44 @@
|
|||
import { describe, it, expect } from 'bun:test';
|
||||
import { describe, expect } from 'vitest';
|
||||
import { render } from "@solidjs/testing-library"
|
||||
import { Table } from './table';
|
||||
import { createDataSet } from './dataset';
|
||||
import { createDataSet } from '~/features/dataset';
|
||||
import { createSignal } from 'solid-js';
|
||||
import { it } from '~/test-helpers';
|
||||
|
||||
type TableItem = {};
|
||||
describe('`<Table />`', () => {
|
||||
it('should render', async () => {
|
||||
const result = render(() => {
|
||||
const [data] = createSignal([]);
|
||||
const dataset = createDataSet(data);
|
||||
|
||||
// describe('<Table />', () => {
|
||||
// it('should render', async () => {
|
||||
// const dataset = createDataSet<TableItem>([]);
|
||||
// const result = render(() => <Table rows={dataset} columns={[]} />);
|
||||
return <Table rows={dataset} columns={[]} />;
|
||||
});
|
||||
|
||||
// expect(true).toBe(true);
|
||||
// });
|
||||
// });
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('should render with groups', async () => {
|
||||
const result = render(() => {
|
||||
const [data] = createSignal([
|
||||
{ id: '1', name: 'a first name', amount: 30, group: 'a' },
|
||||
{ id: '2', name: 'a second name', amount: 20, group: 'a' },
|
||||
{ id: '3', name: 'a third name', amount: 10, group: 'a' },
|
||||
{ id: '4', name: 'a first name', amount: 30, group: 'b' },
|
||||
{ id: '5', name: 'a second name', amount: 20, group: 'b' },
|
||||
{ id: '6', name: 'a third name', amount: 10, group: 'b' },
|
||||
]);
|
||||
const dataset = createDataSet(data, {
|
||||
group: { by: 'group' }
|
||||
});
|
||||
|
||||
return <Table rows={dataset} columns={[
|
||||
{ id: 'id', label: 'id' },
|
||||
{ id: 'name', label: 'name' },
|
||||
{ id: 'amount', label: 'amount' },
|
||||
{ id: 'group', label: 'group' },
|
||||
]} />;
|
||||
});
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
|
@ -10,15 +10,25 @@
|
|||
unicode-bidi: plaintext;
|
||||
cursor: text;
|
||||
|
||||
& [data-marker="spelling"] {
|
||||
& ::highlight(search-results) {
|
||||
background-color: var(--secondary-900);
|
||||
}
|
||||
|
||||
& ::highlight(spelling-error) {
|
||||
text-decoration-line: spelling-error;
|
||||
}
|
||||
|
||||
& [data-marker="grammar"] {
|
||||
& ::highlight(grammar-error) {
|
||||
text-decoration-line: grammar-error;
|
||||
}
|
||||
}
|
||||
|
||||
.search {
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
inset-block-start: 0;
|
||||
}
|
||||
|
||||
.suggestions {
|
||||
position-anchor: --suggestions;
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Accessor, Component, createContext, createEffect, createMemo, createSignal, For, onMount, untrack, useContext } from 'solid-js';
|
||||
import { Component, createEffect, createMemo, createSignal, For, onMount, untrack } from 'solid-js';
|
||||
import { debounce } from '@solid-primitives/scheduled';
|
||||
import { createSelection } from '@solid-primitives/selection';
|
||||
import { createSelection, getTextNodes } from '@solid-primitives/selection';
|
||||
import { createSource } from '~/features/source';
|
||||
import { isServer } from 'solid-js/web';
|
||||
import css from './textarea.module.css';
|
||||
|
@ -30,7 +30,7 @@ export function Textarea(props: TextareaProps) {
|
|||
});
|
||||
|
||||
const mutate = debounce(() => {
|
||||
const [el, start, end] = selection();
|
||||
const [, start, end] = selection();
|
||||
const ref = editorRef();
|
||||
|
||||
if (ref) {
|
||||
|
@ -74,8 +74,21 @@ export function Textarea(props: TextareaProps) {
|
|||
// });
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
createHighlights(editorRef()!, 'spelling-error', source.spellingErrors);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
createHighlights(editorRef()!, 'grammar-error', source.grammarErrors);
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
createHighlights(editorRef()!, 'search-results', source.queryResults);
|
||||
});
|
||||
|
||||
return <>
|
||||
<Suggestions />
|
||||
<input class={css.search} type="search" oninput={e => source.query = e.target.value} />
|
||||
<div
|
||||
ref={setEditorRef}
|
||||
class={`${css.textarea} ${props.class}`}
|
||||
|
@ -161,4 +174,52 @@ const findMarkerNode = (node: Node | null) => {
|
|||
}
|
||||
|
||||
return node;
|
||||
};
|
||||
};
|
||||
|
||||
const spellChecker = checker(/\w+/gi);
|
||||
const grammarChecker = checker(/\w+\s+\w+/gi);
|
||||
|
||||
function checker(regex: RegExp) {
|
||||
return (subject: string, lang: string): [number, number][] => {
|
||||
// return [];
|
||||
|
||||
const threshold = .75//.99;
|
||||
|
||||
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => {
|
||||
return [index, index + match.length] as const;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const createHighlights = (node: Node, type: string, ranges: [number, number][]) => {
|
||||
queueMicrotask(() => {
|
||||
const nodes = getTextNodes(node);
|
||||
|
||||
CSS.highlights.set(type, new Highlight(...ranges.map(([start, end]) => indicesToRange(start, end, nodes))));
|
||||
});
|
||||
};
|
||||
|
||||
const indicesToRange = (start: number, end: number, textNodes: Node[]) => {
|
||||
const [startNode, startPos] = getRangeArgs(start, textNodes);
|
||||
const [endNode, endPos] = start === end ? [startNode, startPos] : getRangeArgs(end, textNodes);
|
||||
|
||||
const range = new Range();
|
||||
|
||||
if (startNode && endNode && startPos !== -1 && endPos !== -1) {
|
||||
range.setStart(startNode, startPos);
|
||||
range.setEnd(endNode, endPos);
|
||||
}
|
||||
|
||||
return range;
|
||||
}
|
||||
|
||||
const getRangeArgs = (offset: number, texts: Node[]): [node: Node | null, offset: number] =>
|
||||
texts.reduce(
|
||||
([node, pos], text) =>
|
||||
node
|
||||
? [node, pos]
|
||||
: pos <= (text as Text).data.length
|
||||
? [text, pos]
|
||||
: [null, pos - (text as Text).data.length],
|
||||
[null, offset] as [node: Node | null, pos: number],
|
||||
);
|
|
@ -36,8 +36,8 @@
|
|||
background-color: color(from var(--info) xyz x y z / .5);
|
||||
}
|
||||
|
||||
& b {
|
||||
color: var(--text-1);
|
||||
& ::highlight(command-pelette-query) {
|
||||
background-color: var(--secondary-900);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,6 +4,7 @@ import { CommandType } from "./command";
|
|||
import { useCommands } from "./context";
|
||||
import css from "./palette.module.css";
|
||||
import { split_by_filter } from "~/utilities";
|
||||
import { getTextNodes } from "@solid-primitives/selection";
|
||||
|
||||
export interface CommandPaletteApi {
|
||||
readonly open: Accessor<boolean>;
|
||||
|
@ -56,14 +57,9 @@ export const CommandPalette: Component<{ api?: (api: CommandPaletteApi) => any,
|
|||
};
|
||||
|
||||
return <dialog ref={setRoot} class={css.commandPalette} onClose={() => setOpen(false)}>
|
||||
<SearchableList title="command palette" items={context.commands()} keySelector={item => t(item.label) as string} context={setSearch} onSubmit={onSubmit}>{
|
||||
(item, ctx) => {
|
||||
const label = t(item.label) as string;
|
||||
const filter = ctx.filter().toLowerCase();
|
||||
|
||||
return <For each={split_by_filter(label, filter)}>{
|
||||
([is_hit, part]) => <Show when={is_hit} fallback={part}><b>{part}</b></Show>
|
||||
}</For>;
|
||||
<SearchableList title="command palette" items={context.commands()} keySelector={item => (t(item.label) ?? item.label) as string} context={setSearch} onSubmit={onSubmit}>{
|
||||
(item) => {
|
||||
return <>{t(item.label) ?? item.label}</>;
|
||||
}
|
||||
}</SearchableList>
|
||||
</dialog>;
|
||||
|
@ -86,7 +82,7 @@ interface SearchableListProps<T> {
|
|||
title?: string;
|
||||
keySelector(item: T): string;
|
||||
filter?: (item: T, search: string) => boolean;
|
||||
children(item: T, context: SearchContext<T>): JSX.Element;
|
||||
children(item: T): JSX.Element;
|
||||
context?: (context: SearchContext<T>) => any,
|
||||
onSubmit?: SubmitHandler<T>;
|
||||
}
|
||||
|
@ -94,6 +90,7 @@ interface SearchableListProps<T> {
|
|||
function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
|
||||
const [term, setTerm] = createSignal<string>('');
|
||||
const [selected, setSelected] = createSignal<number>(0);
|
||||
const [outputRef, setOutputRef] = createSignal<HTMLElement>();
|
||||
const id = createUniqueId();
|
||||
|
||||
const results = createMemo(() => {
|
||||
|
@ -131,6 +128,25 @@ function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
|
|||
setSelected(current => Math.min(current, length));
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const filter = term();
|
||||
const regexp = new RegExp(filter, 'gi');
|
||||
const ref = outputRef()!;
|
||||
|
||||
const ranges = getTextNodes(ref).flatMap(node => {
|
||||
return node.textContent!.matchAll(regexp).map(({ index }) => {
|
||||
const range = new Range();
|
||||
|
||||
range.setStart(node, index);
|
||||
range.setEnd(node, index + filter.length);
|
||||
|
||||
return range;
|
||||
}).toArray();
|
||||
});
|
||||
|
||||
CSS.highlights.set('command-pelette-query', new Highlight(...ranges));
|
||||
});
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'ArrowUp') {
|
||||
setSelected(current => Math.max(0, current - 1));
|
||||
|
@ -162,9 +178,9 @@ function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
|
|||
<form method="dialog" class={css.search} onkeydown={onKeyDown} onsubmit={onSubmit}>
|
||||
<input id={`search-${id}`} value={term()} onInput={(e) => setTerm(e.target.value)} placeholder="start typing for command" autofocus autocomplete="off" enterkeyhint="go" />
|
||||
|
||||
<output for={`search-${id}`}>
|
||||
<output ref={setOutputRef} for={`search-${id}`}>
|
||||
<For each={results()}>{
|
||||
(result, index) => <div class={`${index() === selected() ? css.selected : ''}`}>{props.children(result, ctx)}</div>
|
||||
(result, index) => <div class={`${index() === selected() ? css.selected : ''}`}>{props.children(result)}</div>
|
||||
}</For>
|
||||
</output>
|
||||
</form>
|
||||
|
@ -172,5 +188,4 @@ function SearchableList<T>(props: SearchableListProps<T>): JSX.Element {
|
|||
};
|
||||
|
||||
let keyCounter = 0;
|
||||
const createUniqueId = () => `key-${keyCounter++}`;
|
||||
|
||||
const createUniqueId = () => `key-${keyCounter++}`;
|
|
@ -1,6 +1,10 @@
|
|||
import { describe, expect, it } from "bun:test";
|
||||
// import { describe, expect, it } from "bun:test";
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { createDataSet } from "./index";
|
||||
import { createSignal } from "solid-js";
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
import { testEffect, } from "@solidjs/testing-library";
|
||||
import { describe, expect } from "vitest";
|
||||
import { it } from '~/test-helpers';
|
||||
|
||||
interface DataEntry {
|
||||
id: string;
|
||||
|
@ -49,6 +53,38 @@ describe('dataset', () => {
|
|||
expect(actual).toEqual(expect.objectContaining({ value: defaultData() }))
|
||||
});
|
||||
|
||||
it('update if the source value changes', () => {
|
||||
// Arrange
|
||||
const [data, setData] = createSignal([
|
||||
{ id: '1', name: 'a first name', amount: 30 },
|
||||
{ id: '2', name: 'a second name', amount: 20 },
|
||||
{ id: '3', name: 'a third name', amount: 10 },
|
||||
]);
|
||||
const dataset = createDataSet(data);
|
||||
|
||||
dataset.mutateEach(item => ({ ...item, amount: item.amount * 2 }));
|
||||
|
||||
// Act
|
||||
setData([
|
||||
{ id: '4', name: 'a first name', amount: 30 },
|
||||
{ id: '5', name: 'a second name', amount: 20 },
|
||||
{ id: '6', name: 'a third name', amount: 10 },
|
||||
]);
|
||||
|
||||
// Assert
|
||||
return testEffect(done =>
|
||||
createEffect(() => {
|
||||
expect(dataset.value).toEqual([
|
||||
{ id: '4', name: 'a first name', amount: 60 },
|
||||
{ id: '5', name: 'a second name', amount: 40 },
|
||||
{ id: '6', name: 'a third name', amount: 20 },
|
||||
])
|
||||
|
||||
done()
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('mutate', () => {
|
||||
it('mutates the value', async () => {
|
||||
// Arrange
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { Accessor, createEffect, createMemo, untrack } from "solid-js";
|
||||
import { createStore, produce } from "solid-js/store";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { CustomPartial } from "solid-js/store/types/store.js";
|
||||
import { deepCopy, deepDiff, MutarionKind, Mutation } from "~/utilities";
|
||||
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { Accessor, createEffect, createResource, createSignal, InitializedResource, onCleanup, Resource } from "solid-js";
|
||||
import { Accessor, createEffect, from, createSignal } from "solid-js";
|
||||
import { json } from "./parser";
|
||||
import { filter } from "~/utilities";
|
||||
|
||||
|
@ -88,17 +88,11 @@ function createPolled<S, T>(source: Accessor<S>, callback: (source: S, prev: T)
|
|||
};
|
||||
|
||||
function createTicker(interval: number): Accessor<boolean> {
|
||||
const [tick, update] = createSignal(true);
|
||||
return from(set => {
|
||||
const ref = setInterval(() => set((v = true) => !v), interval);
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
update(v => !v);
|
||||
}, interval);
|
||||
|
||||
onCleanup(() => {
|
||||
clearInterval(intervalId);
|
||||
});
|
||||
|
||||
return tick;
|
||||
return () => clearInterval(ref);
|
||||
}) as Accessor<boolean>;
|
||||
}
|
||||
|
||||
async function* walk(directory: FileSystemDirectoryHandle, path: string[] = []): AsyncGenerator<{ id: string, handle: FileSystemFileHandle, path: string[], file: File }, void, never> {
|
||||
|
|
49
src/features/source/source.spec.ts
Normal file
49
src/features/source/source.spec.ts
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { describe, expect } from "vitest";
|
||||
import { createSource } from "./source";
|
||||
import { it } from "~/test-helpers";
|
||||
import { testEffect } from "@solidjs/testing-library";
|
||||
import { createEffect, createSignal } from "solid-js";
|
||||
|
||||
describe('Source', () => {
|
||||
describe('Source', () => {
|
||||
it('should return a `Source`', () => {
|
||||
// Arrange
|
||||
|
||||
// Act
|
||||
const actual = createSource('');
|
||||
|
||||
// Assert
|
||||
expect(actual.out).toBe('');
|
||||
});
|
||||
|
||||
it('should transform the input format to output format', () => {
|
||||
// Arrange
|
||||
const given = '**text**\n';
|
||||
const expected = '<p><strong>text</strong></p>';
|
||||
|
||||
// Act
|
||||
const actual = createSource(given);
|
||||
|
||||
// Assert
|
||||
expect(actual.out).toBe(expected);
|
||||
});
|
||||
|
||||
it('should contain query results', () => {
|
||||
// Arrange
|
||||
const expected: [number, number][] = [[8, 9], [12, 13], [15, 16]];
|
||||
const source = createSource('this is a seachable string');
|
||||
|
||||
// Act
|
||||
source.query = 'a';
|
||||
|
||||
// Assert
|
||||
return testEffect(done => {
|
||||
createEffect(() => {
|
||||
expect(source.queryResults).toEqual(expected);
|
||||
|
||||
done()
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,29 +1,59 @@
|
|||
import { onMount } from "solid-js";
|
||||
import { createEffect, onMount } from "solid-js";
|
||||
import { createStore } from "solid-js/store";
|
||||
import { unified, Transformer } from 'unified'
|
||||
import { Node, Text, Element } from 'hast'
|
||||
import { unified } from 'unified'
|
||||
import { Text, Root } from 'hast'
|
||||
import { visit } from "unist-util-visit";
|
||||
import { decode } from "~/utilities";
|
||||
import remarkParse from 'remark-parse'
|
||||
import remarkRehype from 'remark-rehype'
|
||||
import remarkStringify from 'remark-stringify'
|
||||
import rehypeParse from 'rehype-dom-parse'
|
||||
import rehypeParse from 'rehype-parse'
|
||||
import rehypeRemark from 'rehype-remark'
|
||||
import rehypeStringify from 'rehype-dom-stringify'
|
||||
import rehypeStringify from 'rehype-stringify'
|
||||
|
||||
interface SourceStore {
|
||||
in: string;
|
||||
out: string;
|
||||
plain: string;
|
||||
query: string;
|
||||
metadata: {
|
||||
spellingErrors: [number, number][];
|
||||
grammarErrors: [number, number][];
|
||||
queryResults: [number, number][];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
in: string;
|
||||
out: string;
|
||||
query: string;
|
||||
readonly spellingErrors: [number, number][];
|
||||
readonly grammarErrors: [number, number][];
|
||||
readonly queryResults: [number, number][];
|
||||
}
|
||||
|
||||
// TODO :: make this configurable, right now we can only do markdown <--> html.
|
||||
const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(addErrors).use(rehypeStringify);
|
||||
const outToInProcessor = unified().use(rehypeParse).use(clearErrors).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
|
||||
const inToOutProcessor = unified().use(remarkParse).use(remarkRehype).use(rehypeStringify);
|
||||
const outToInProcessor = unified().use(rehypeParse).use(rehypeRemark).use(remarkStringify, { bullet: '-' });
|
||||
|
||||
export function createSource(initalValue: string): Source {
|
||||
const [store, setStore] = createStore({ in: initalValue, out: '' });
|
||||
const ast = inToOutProcessor.runSync(inToOutProcessor.parse(initalValue));
|
||||
const out = String(inToOutProcessor.stringify(ast));
|
||||
const plain = String(unified().use(plainTextStringify).stringify(ast));
|
||||
|
||||
onMount(() => {
|
||||
setStore('out', String(inToOutProcessor.processSync(initalValue)));
|
||||
const [store, setStore] = createStore<SourceStore>({ in: initalValue, out, plain, query: '', metadata: { spellingErrors: [], grammarErrors: [], queryResults: [] } });
|
||||
|
||||
createEffect(() => {
|
||||
const value = store.plain;
|
||||
|
||||
setStore('metadata', {
|
||||
spellingErrors: spellChecker(value, ''),
|
||||
grammarErrors: grammarChecker(value, ''),
|
||||
});
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
setStore('metadata', 'queryResults', findMatches(store.plain, store.query).toArray());
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -31,9 +61,12 @@ export function createSource(initalValue: string): Source {
|
|||
return store.in;
|
||||
},
|
||||
set in(next) {
|
||||
const ast = inToOutProcessor.runSync(inToOutProcessor.parse(next));
|
||||
|
||||
setStore({
|
||||
in: next,
|
||||
out: String(inToOutProcessor.processSync(next)),
|
||||
out: String(inToOutProcessor.stringify(ast)),
|
||||
plain: String(unified().use(plainTextStringify).stringify(ast)),
|
||||
});
|
||||
},
|
||||
|
||||
|
@ -41,84 +74,67 @@ export function createSource(initalValue: string): Source {
|
|||
return store.out;
|
||||
},
|
||||
set out(next) {
|
||||
const ast = outToInProcessor.parse(next);
|
||||
|
||||
setStore({
|
||||
in: String(outToInProcessor.processSync(next)).trim(),
|
||||
in: String(outToInProcessor.stringify(outToInProcessor.runSync(ast))).trim(),
|
||||
out: next,
|
||||
plain: String(unified().use(plainTextStringify).stringify(ast)),
|
||||
});
|
||||
},
|
||||
|
||||
get query() {
|
||||
return store.query;
|
||||
},
|
||||
set query(next) {
|
||||
setStore('query', next)
|
||||
},
|
||||
|
||||
get spellingErrors() {
|
||||
return store.metadata.spellingErrors;
|
||||
},
|
||||
|
||||
get grammarErrors() {
|
||||
return store.metadata.grammarErrors;
|
||||
},
|
||||
|
||||
get queryResults() {
|
||||
return store.metadata.queryResults;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const isMarker = (node: Node) => node.type === 'element' && Object.hasOwn((node as Element).properties, 'dataMarker')
|
||||
function plainTextStringify() {
|
||||
this.compiler = function (tree: Root) {
|
||||
const nodes: string[] = [];
|
||||
|
||||
function addErrors(): Transformer {
|
||||
const wrapInMarker = (text: Text, type: string): Element => ({
|
||||
type: 'element',
|
||||
tagName: 'span',
|
||||
properties: {
|
||||
dataMarker: type,
|
||||
},
|
||||
children: [
|
||||
text
|
||||
]
|
||||
});
|
||||
|
||||
return function (tree) {
|
||||
visit(tree, n => n.type === 'text', (n, i, p: Element) => {
|
||||
if (typeof i !== 'number' || p === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarker(p)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = grammarChecker(n.value, 'en-GB');
|
||||
|
||||
if (errors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
p.children.splice(i, 1, ...errors.map(([isHit, value]) => {
|
||||
const textNode: Text = { type: 'text', value };
|
||||
|
||||
return isHit ? wrapInMarker(textNode, 'grammar') : textNode;
|
||||
}))
|
||||
visit(tree, n => n.type === 'text', (n) => {
|
||||
nodes.push((n as Text).value);
|
||||
});
|
||||
|
||||
visit(tree, n => n.type === 'text', (n, i, p: Element) => {
|
||||
if (typeof i !== 'number' || p === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isMarker(p)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errors = spellChecker(n.value, 'en-GB');
|
||||
|
||||
if (errors.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
p.children.splice(i, 1, ...errors.map(([isHit, value]) => {
|
||||
const textNode: Text = { type: 'text', value };
|
||||
|
||||
return isHit ? wrapInMarker(textNode, 'spelling') : textNode;
|
||||
}))
|
||||
});
|
||||
}
|
||||
return decode(nodes.join(''));
|
||||
};
|
||||
}
|
||||
|
||||
function clearErrors(): Transformer {
|
||||
return function (tree) {
|
||||
visit(tree, isMarker, (n, i, p: Element) => {
|
||||
if (typeof i !== 'number' || p === undefined) {
|
||||
return;
|
||||
}
|
||||
function* findMatches(text: string, query: string): Generator<[number, number], void, unknown> {
|
||||
if (query.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
p.children.splice(i, 1, ...(n as Element).children);
|
||||
})
|
||||
let startIndex = 0;
|
||||
|
||||
while (startIndex < text.length) {
|
||||
const index = text.indexOf(query, startIndex);
|
||||
|
||||
if (index === -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const end = index + query.length;
|
||||
|
||||
yield [index, end];
|
||||
|
||||
startIndex = end;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -126,22 +142,13 @@ const spellChecker = checker(/\w+/gi);
|
|||
const grammarChecker = checker(/\w+\s+\w+/gi);
|
||||
|
||||
function checker(regex: RegExp) {
|
||||
return (subject: string, lang: string): (readonly [boolean, string])[] => {
|
||||
return (subject: string, lang: string): [number, number][] => {
|
||||
return [];
|
||||
|
||||
let lastIndex = 0;
|
||||
const threshold = .75//.99;
|
||||
|
||||
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).flatMap<readonly [boolean, string]>(({ 0: match, index }) => {
|
||||
const end = index + match.length;
|
||||
const result = [
|
||||
[false, subject.slice(lastIndex, index)],
|
||||
[true, subject.slice(index, end)],
|
||||
] as const;
|
||||
|
||||
lastIndex = end;
|
||||
|
||||
return result;
|
||||
}).concat([[false, subject.slice(lastIndex, subject.length)]]);
|
||||
return Array.from<RegExpExecArray>(subject.matchAll(regex)).filter(() => Math.random() >= threshold).map(({ 0: match, index }) => {
|
||||
return [index, index + match.length] as const;
|
||||
});
|
||||
}
|
||||
}
|
|
@ -9,7 +9,7 @@
|
|||
"title": "Hi, welcome!",
|
||||
"subtitle": "Lets get started",
|
||||
"edit": "Start editing",
|
||||
"instructions": "Read the instructions",
|
||||
"instructions": "Read the **instructions**",
|
||||
"about": "Abut this app"
|
||||
},
|
||||
"edit": {
|
||||
|
@ -51,4 +51,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,8 +9,9 @@ import { HttpHeader } from "@solidjs/start";
|
|||
import { FaSolidPalette } from "solid-icons/fa";
|
||||
import { LocalePicker } from "~/features/i18n";
|
||||
import { ColorScheme, ColorSchemePicker, getState, useTheme } from "~/features/theme";
|
||||
import css from "./editor.module.css";
|
||||
import { Dropdown } from "~/components/dropdown";
|
||||
import { ErrorComp } from "~/components/error";
|
||||
import css from "./editor.module.css";
|
||||
|
||||
const event = getRequestEvent();
|
||||
|
||||
|
@ -99,19 +100,5 @@ export default function Editor(props: ParentProps) {
|
|||
</MenuProvider>
|
||||
}
|
||||
|
||||
const ErrorComp: Component<{ error: Error }> = (props) => {
|
||||
return <div class={css.error}>
|
||||
<b>{props.error.message}</b>
|
||||
|
||||
<Show when={props.error.cause}>{
|
||||
cause => <>{cause().description}</>
|
||||
}</Show>
|
||||
|
||||
{props.error.stack}
|
||||
|
||||
<a href="/">Return to start</a>
|
||||
</div>;
|
||||
};
|
||||
|
||||
let keyCounter = 0;
|
||||
const createUniqueId = () => `key-${keyCounter++}`;
|
|
@ -1,8 +1,9 @@
|
|||
|
||||
import { ParentProps } from "solid-js";
|
||||
import { ErrorBoundary, ParentProps } from "solid-js";
|
||||
import { Menu } from "~/features/menu";
|
||||
import { createCommand } from "~/features/command";
|
||||
import { useNavigate } from "@solidjs/router";
|
||||
import { ErrorComp } from "~/components/error";
|
||||
|
||||
export default function Experimental(props: ParentProps) {
|
||||
const navigate = useNavigate();
|
||||
|
@ -19,6 +20,8 @@ export default function Experimental(props: ParentProps) {
|
|||
<Menu.Item command={goTo.withLabel('formatter').with('formatter')} />
|
||||
</Menu.Root>
|
||||
|
||||
{props.children}
|
||||
<ErrorBoundary fallback={e => <ErrorComp error={e} />}>
|
||||
{props.children}
|
||||
</ErrorBoundary>
|
||||
</>;
|
||||
}
|
|
@ -1,4 +1,5 @@
|
|||
.root {
|
||||
position: relative;
|
||||
margin: 1em;
|
||||
padding: .5em;
|
||||
gap: 1em;
|
||||
|
|
|
@ -108,23 +108,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
|
||||
background: repeating-linear-gradient(-45deg,
|
||||
color(from var(--fail) xyz x y z / .05),
|
||||
color(from var(--fail) xyz x y z / .05) 10px,
|
||||
color(from var(--fail) xyz x y z / .25) 10px,
|
||||
color(from var(--fail) xyz x y z / .25) 12px,
|
||||
color(from var(--fail) xyz x y z / .05) 12px);
|
||||
color: var(--text-2);
|
||||
border: 1px solid var(--fail);
|
||||
border-radius: var(--radii-m);
|
||||
|
||||
margin: var(--padding-l);
|
||||
}
|
||||
|
||||
@keyframes slide-left {
|
||||
from {
|
||||
translate: 0% 0;
|
||||
|
|
14
src/test-helpers.ts
Normal file
14
src/test-helpers.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { createRoot } from 'solid-js';
|
||||
import { it as vit } from 'vitest';
|
||||
|
||||
export const it = (name: string, fn: () => any) => {
|
||||
return vit(name, () => {
|
||||
return createRoot(async (cleanup) => {
|
||||
const res = await fn();
|
||||
|
||||
cleanup();
|
||||
|
||||
return res;
|
||||
});
|
||||
})
|
||||
}
|
|
@ -1,20 +1,9 @@
|
|||
import { describe, beforeEach, it, expect, afterAll, spyOn } from 'bun:test';
|
||||
import { describe, expect, vi } from 'vitest';
|
||||
import { decode, deepCopy, deepDiff, filter, gen__split_by_filter, map, MutarionKind, split_by_filter, splitAt } from './utilities';
|
||||
import { install } from '@sinonjs/fake-timers';
|
||||
import { it } from '~/test-helpers';
|
||||
|
||||
type MilliSeconds = number;
|
||||
const useFakeTimers = () => {
|
||||
const clock = install();
|
||||
const { spyOn } = vi;
|
||||
|
||||
beforeEach(() => clock.reset());
|
||||
afterAll(() => clock.uninstall());
|
||||
|
||||
return {
|
||||
tick(timeToAdvance: MilliSeconds) {
|
||||
clock.tick(timeToAdvance);
|
||||
},
|
||||
};
|
||||
};
|
||||
const first = <T>(iterable: Iterable<T>): T | undefined => {
|
||||
for (const value of iterable) {
|
||||
return value;
|
||||
|
@ -123,6 +112,18 @@ describe('utilities', () => {
|
|||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should decode \\b characters', async () => {
|
||||
// Arrange
|
||||
const given = 'this is\\ba string';
|
||||
const expected = 'this is\ba string';
|
||||
|
||||
// Act
|
||||
const actual = decode(given);
|
||||
|
||||
// Assert
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should decode \\n characters', async () => {
|
||||
// Arrange
|
||||
const given = 'this is\\na string';
|
||||
|
@ -135,6 +136,54 @@ describe('utilities', () => {
|
|||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should decode \\r characters', async () => {
|
||||
// Arrange
|
||||
const given = 'this is\\ra string';
|
||||
const expected = 'this is\ra string';
|
||||
|
||||
// Act
|
||||
const actual = decode(given);
|
||||
|
||||
// Assert
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should decode \\f characters', async () => {
|
||||
// Arrange
|
||||
const given = 'this is\\fa string';
|
||||
const expected = 'this is\fa string';
|
||||
|
||||
// Act
|
||||
const actual = decode(given);
|
||||
|
||||
// Assert
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should decode \' characters', async () => {
|
||||
// Arrange
|
||||
const given = 'this is\\\'a string';
|
||||
const expected = 'this is\'a string';
|
||||
|
||||
// Act
|
||||
const actual = decode(given);
|
||||
|
||||
// Assert
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should decode \" characters', async () => {
|
||||
// Arrange
|
||||
const given = 'this is\"a string';
|
||||
const expected = 'this is"a string';
|
||||
|
||||
// Act
|
||||
const actual = decode(given);
|
||||
|
||||
// Assert
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should decode \\uHHHH characters', async () => {
|
||||
// Arrange
|
||||
const given = 'this is \\u1234 a string';
|
||||
|
@ -298,6 +347,18 @@ describe('utilities', () => {
|
|||
expect(actual).toEqual({ kind: MutarionKind.Create, key: 'key', value: 'value' });
|
||||
});
|
||||
|
||||
it('should yield a mutation of type create when `b` contains a value that `a` does not', async () => {
|
||||
// arrange
|
||||
const a = { key: null };
|
||||
const b = { key: 'value' };
|
||||
|
||||
// Act
|
||||
const actual = first(deepDiff(a, b));
|
||||
|
||||
// Arrange
|
||||
expect(actual).toEqual({ kind: MutarionKind.Create, key: 'key', value: 'value' });
|
||||
});
|
||||
|
||||
it('should yield a mutation of type delete when `a` contains a key that `b` does not', async () => {
|
||||
// arrange
|
||||
const a = { key: 'value' };
|
||||
|
@ -310,6 +371,18 @@ describe('utilities', () => {
|
|||
expect(actual).toEqual({ kind: MutarionKind.Delete, key: 'key', original: 'value' });
|
||||
});
|
||||
|
||||
it('should yield a mutation of type delete when `a` contains a key that `b` does not', async () => {
|
||||
// arrange
|
||||
const a = { key: 'value' };
|
||||
const b = { key: undefined };
|
||||
|
||||
// Act
|
||||
const actual = first(deepDiff(a, b));
|
||||
|
||||
// Arrange
|
||||
expect(actual).toEqual({ kind: MutarionKind.Delete, key: 'key', original: 'value' });
|
||||
});
|
||||
|
||||
it('should yield a mutation of type update when the value of a key in `a` is not equal to the value of the same key in `b`', async () => {
|
||||
// arrange
|
||||
const a = { key: 'old' };
|
||||
|
|
|
@ -27,8 +27,11 @@ export function split_by_filter(subject: string, filter: string): (readonly [boo
|
|||
return Array.from<readonly [boolean, string]>(gen__split_by_filter(subject, filter));
|
||||
}
|
||||
|
||||
type Hex = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 'a' | 'b' | 'c' | 'd' | 'e' | 'f';
|
||||
type EncodedChar = 't' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | `u${Hex}${Hex | ''}${Hex | ''}${Hex | ''}`
|
||||
|
||||
const decodeRegex = /(?<!\\)\\(t|b|n|r|f|'|"|u[0-9a-f]{1,4})/gi;
|
||||
const decodeReplacer = (_: any, char: string) => ({
|
||||
const decodeReplacer = (_: any, char: EncodedChar) => ({
|
||||
t: '\t',
|
||||
b: '\b',
|
||||
n: '\n',
|
||||
|
@ -37,7 +40,7 @@ const decodeReplacer = (_: any, char: string) => ({
|
|||
"'": '\'',
|
||||
'"': '\"',
|
||||
u: String.fromCharCode(Number.parseInt(`0x${char.slice(1)}`)),
|
||||
}[char.charAt(0)] ?? '');
|
||||
}[char.charAt(0) as ('t' | 'b' | 'n' | 'r' | 'f' | '\'' | '"' | 'u')]);
|
||||
export const decode = (subject: string): string => subject.replace(decodeRegex, decodeReplacer);
|
||||
|
||||
export const deepCopy = <T>(original: T): T => {
|
||||
|
@ -110,8 +113,13 @@ export function* deepDiff<T1 extends object, T2 extends object>(a: T1, b: T2, pa
|
|||
const key = path.concat(keyA!.toString()).join('.');
|
||||
|
||||
yield ((): Mutation => {
|
||||
if (valueA === null || valueA === undefined) return { key, kind: MutarionKind.Create, value: valueB };
|
||||
if (valueB === null || valueB === undefined) return { key, kind: MutarionKind.Delete, original: valueA };
|
||||
if (valueA === null || valueA === undefined) {
|
||||
return { key, kind: MutarionKind.Create, value: valueB };
|
||||
}
|
||||
|
||||
if (valueB === null || valueB === undefined) {
|
||||
return { key, kind: MutarionKind.Delete, original: valueA };
|
||||
}
|
||||
|
||||
return { key, kind: MutarionKind.Update, value: valueB, original: valueA };
|
||||
})();
|
||||
|
@ -200,7 +208,7 @@ const bufferredIterator = <T extends readonly [string | number, any]>(subject: I
|
|||
|
||||
const next = () => {
|
||||
const res = iterator.next();
|
||||
done = res.done ?? false;
|
||||
done = res.done!;
|
||||
|
||||
if (!done) {
|
||||
buffer.push(res.value);
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue