Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support javascript snippet files #219

Merged
merged 16 commits into from
Oct 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions DOCS.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
# Documentation
## Snippets

A snippets file is a JavaScript array of snippets, or a JavaScript module with a default export of an array of snippets.

_Note: an **array** is a list of items. In JavaScript, this is represented with the bracket symbols `[` `]`, with the items between them and separated by commas, e.g. `[ item1, item2 ]`._

Snippets are formatted as follows:

```typescript
{trigger: string, replacement: string, options: string, description?: string, priority?: number}
{trigger: string | RegExp, replacement: string, options: string, flags?: string, description?: string, priority?: number}
```

- `trigger` : The text that triggers this snippet.
- `trigger` : The text that triggers this snippet. In JavaScript snippet files, it can also be a `RegExp` literal.
- `replacement` : The text to replace the `trigger` with.
- `options` : See below.
- `flags` (optional): Flags for [regex snippets](#regex). Not applicable to non-regex snippets. The following flags are permitted: `i`, `m`, `s`, `u`, `v`.
- `priority` (optional): This snippet's priority. Snippets with higher priority are run first. Can be negative. Defaults to 0.
- `description` (optional): A description for this snippet.

Expand Down
9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Snippets are formatted as follows:
- `trigger` : The text that triggers this snippet.
- `replacement` : The text to replace the `trigger` with.
- `options` : See below.
- `flags` (optional): Flags for regex snippets.
- `description` (optional): A description for this snippet.
- `priority` (optional): This snippet's priority. Defaults to 0. Snippets with higher priority are run first. Can be negative.

Expand All @@ -149,8 +150,10 @@ Insert **tabstops** for the cursor to jump to by writing "$0", "$1", etc. in the

For more details on writing snippets, including **regex** snippets, [see the documentation here](DOCS.md). You can [view snippets written by others and share your own snippets here](https://github.com/artisticat1/obsidian-latex-suite/discussions/50).



> ❗ **Warning**
>
> Snippet files are interpreted as JavaScript and can execute arbitrary code.
> Always be careful with snippets shared from others to avoid running malicious code.

## Cheatsheet

Expand Down Expand Up @@ -183,7 +186,7 @@ When running a snippet that **moves the cursor inside brackets {}, press <kbd>Ta
### Greek letters

| Trigger | Replacement | Trigger | Replacement |
|---------|--------------|---------|-------------|
| ------- | ------------ | ------- | ----------- |
| @a | \\alpha | eta | \\eta |
| @b | \\beta | mu | \\mu |
| @g | \\gamma | nu | \\nu |
Expand Down
49 changes: 22 additions & 27 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,6 @@
"author": "",
"license": "MIT",
"devDependencies": {
"@popperjs/core": "2.11.5",
"codemirror": "^6.0.0",
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
Expand All @@ -22,14 +20,16 @@
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.0.3",
"@popperjs/core": "2.11.5",
"@types/node": "^16.11.6",
"@typescript-eslint/eslint-plugin": "^5.2.0",
"@typescript-eslint/parser": "^5.2.0",
"@lezer/common": "^1.0.3",
"builtin-modules": "^3.2.0",
"codemirror": "^6.0.0",
"esbuild": "0.13.12",
"eslint": "^8.12.0",
"json5": "^2.2.1",
"js-base64": "^3.7.5",
"obsidian": "^0.15.0",
"tslib": "2.3.1",
"typescript": "4.4.4"
Expand Down
2 changes: 1 addition & 1 deletion src/features/run_snippets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ const processSnippet = (snippet: ParsedSnippet, effectiveLine: string, range: S

// Add $ to match the end of the string
// i.e. look for a match at the cursor's current position
const regex = new RegExp(trigger + "$");
const regex = new RegExp(trigger + "$", snippet.flags);
const result = regex.exec(effectiveLine);

if (!(result)) {
Expand Down
4 changes: 2 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export default class LatexSuitePlugin extends Plugin {

if (this.settings.loadSnippetsFromFile) {
// Use onLayoutReady so that we don't try to read the snippets file too early
const tempSnippets = parseSnippets(this.settings.snippets);
const tempSnippets = await parseSnippets(this.settings.snippets);
this.CMSettings = processLatexSuiteSettings(tempSnippets, this.settings);

this.app.workspace.onLayoutReady(() => {
Expand All @@ -94,7 +94,7 @@ export default class LatexSuitePlugin extends Plugin {

async getSnippets() {
if (!this.settings.loadSnippetsFromFile) {
return parseSnippets(this.settings.snippets);
return await parseSnippets(this.settings.snippets);
}
else {
const snippets = await getSnippetsWithinFileOrFolder(this.settings.snippetsFileLocation);
Expand Down
14 changes: 9 additions & 5 deletions src/settings/file_watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,12 @@ async function getSnippetsWithinFolder(folder: TFolder) {
const content = await this.app.vault.cachedRead(fileOrFolder as TFile);

try {
snippets.push(...parseSnippets(content));
snippets.push(...await parseSnippets(content));
}
catch (e) {
console.log(`Failed to load snippet file ${fileOrFolder.path}:`, e);
new Notice(`Failed to load snippet file ${fileOrFolder.name}`);
}

}
else {
const newSnippets = await getSnippetsWithinFolder(fileOrFolder as TFolder);
Expand All @@ -92,10 +91,15 @@ export async function getSnippetsWithinFileOrFolder(path: string) {

if (fileOrFolder instanceof TFolder) {
snippets = await getSnippetsWithinFolder(fileOrFolder as TFolder);

} else {
const content = await window.app.vault.cachedRead(fileOrFolder as TFile);
snippets = await parseSnippets(content);
try {
const content = await window.app.vault.cachedRead(fileOrFolder as TFile);
snippets = await parseSnippets(content);
}
catch (e) {
console.log(`Failed to load snippet file ${fileOrFolder.path}:`, e);
new Notice(`Failed to load snippet file ${fileOrFolder.name}`);
}
}

// Sorting needs to happen after all the snippet files have been parsed
Expand Down
2 changes: 1 addition & 1 deletion src/settings/settings_tab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,7 @@ export class LatexSuiteSettingTab extends PluginSettingTab {
let success = true;

try {
parseSnippets(value);
await parseSnippets(value);
}
catch (e) {
success = false;
Expand Down
2 changes: 1 addition & 1 deletion src/settings/ui/snippets_editor/obsidian_theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export const config = {
comment: "var(--text-faint)",
heading: "var(--text-accent-hover)",
invalid: "var(--text-error)",
regexp: "#032f62",
regexp: "var(--text-accent)",
}

export const obsidianTheme = EditorView.theme({
Expand Down
71 changes: 53 additions & 18 deletions src/snippets/parse_snippets.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ParsedSnippet, RawSnippet } from "./snippets";
import { parse } from "json5";
import { encode } from "js-base64";

export function sortSnippets(snippets:ParsedSnippet[]) {
// Sort snippets by trigger length so longer snippets will have higher priority
Expand Down Expand Up @@ -36,29 +36,64 @@ export function sortSnippets(snippets:ParsedSnippet[]) {
snippets.sort(comparePriority);
}

/**
* imports the default export of a given module.
*
* @param module the module to import. this can be a resource path, data url, etc
* @returns the default export of said module
* @throws if import fails or default export is undefined
*/
async function importModuleDefault(module: string): Promise<unknown> {
let data;
try {
data = await import(module);
} catch (e) {
throw `failed to import module ${module}`;
}

// it's safe to use `in` here - it has a null prototype, so `Object.hasOwnProperty` isn't available,
// but on the other hand we don't need to worry about something further up the prototype chain messing with this check
if (!("default" in data)) {
throw `No default export provided for module ${module}`;
}

return data.default;
}

export async function parseSnippets(snippetsStr: string) {
let rawSnippets;
try {
try {
// first, try to import as a plain js module
// js-base64.encode is needed over builtin `window.btoa` because the latter errors on unicode
rawSnippets = await importModuleDefault(`data:text/javascript;base64,${encode(snippetsStr)}`);
} catch {
// otherwise, try to import as a standalone js array
rawSnippets = await importModuleDefault(`data:text/javascript;base64,${encode(`export default ${snippetsStr}`)}`);
}
} catch (e) {
throw "Invalid snippet format.";
}

export function parseSnippets(snippetsStr: string) {
const rawSnippets: RawSnippet[] = parse(snippetsStr);
if (!validateSnippets(rawSnippets)) throw "Invalid snippet format.";
if (!validateSnippets(rawSnippets)) { throw "Invalid snippet format."; }

const parsedSnippets = rawSnippets.map(rawSnippet => new ParsedSnippet(rawSnippet));
sortSnippets(parsedSnippets);

return parsedSnippets;
}


export function validateSnippets(snippets: RawSnippet[]):boolean {
let valid = true;

for (const snippet of snippets) {
// Check that the snippet trigger, replacement and options are defined

if (!(snippet.trigger && snippet.replacement && snippet.options != undefined)) {
valid = false;
break;
}
}

return valid;
export function validateSnippets(snippets: unknown): snippets is RawSnippet[] {
if (!Array.isArray(snippets)) { return false; }

return snippets.every(snippet => (
// check that trigger is defined
(typeof snippet.trigger === "string" || snippet.trigger instanceof RegExp)
// check that replacement is defined
&& (typeof snippet.replacement === "string")
// check that options is defined
&& (typeof snippet.options === "string")
// check that flags, if defined, is a string
&& (typeof snippet.flags === "undefined" || typeof snippet.flags === "string")
));
}
Loading