Initial commit

This commit is contained in:
Steven Stallion 2025-03-11 15:03:16 -05:00
commit d629cb3633
28 changed files with 5992 additions and 0 deletions

21
.editorconfig Normal file
View file

@ -0,0 +1,21 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 4
tab_width = 8
insert_final_newline = true
trim_trailing_whitespace = true
max_line_length = 80
[*.md]
trim_trailing_whitespace = false
[*.{mjs,ts}]
max_line_length = 85
[*.{json,yml}]
indent_size = 2

22
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: CI
on:
pull_request:
push:
branches:
- master
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: latest
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run build
- name: Run tests
run: npm test
- name: Run checks
run: npm run check

26
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: Release
on:
push:
tags:
- "*.*.*"
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: latest
- name: Install dependencies
run: npm ci
- name: Build plugin
run: npm run release
- id: extract-release-notes
uses: ffurrer2/extract-release-notes@v2
- uses: softprops/action-gh-release@v2
with:
body: ${{ steps.extract-release-notes.outputs.release_notes }}
files: |
main.js
manifest.json
styles.css

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/node_modules/
/data.json
/main.js
/styles.css

2
.npmrc Normal file
View file

@ -0,0 +1,2 @@
git-tag-version=false
tag-version-prefix=""

6
.prettierignore Normal file
View file

@ -0,0 +1,6 @@
*.*
!*.json
!*.mjs
!*.scss
!*.ts
package-lock.json

10
CHANGELOG.md Normal file
View file

@ -0,0 +1,10 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
[Unreleased]: https://github.com/sstallion/obsidian-command-line/compare/TODO...HEAD

104
CONTRIBUTING.md Normal file
View file

@ -0,0 +1,104 @@
# Contributing
If you have an idea or feature request please open an [issue][1], even if you do
not have time to contribute!
## Making Changes
> [!IMPORTANT]
> This guide assumes you have a functioning [Node.js and npm][2] installation. A
> recent release ([Node.js 20][3] or later) is required to build this plugin.
> Long-term support (LTS) releases are recommended for local development.
To get started, [fork][4] this repository on GitHub and clone a working copy for
development:
```
$ git clone git@github.com:YOUR-USERNAME/obsidian-command-line.git
```
> [!TIP]
> For local development, it is recommended to create the working copy in the
> `plugins` directory. The directory name should match the plugin ID. See the
> [Obsidian Docs][5] for details.
After cloning, install dependencies by issuing:
```
$ npm ci
```
Several scripts are defined in [package.json][6] to aid plugin development. Once
you have finished making changes, run tests by issuing:
```
$ npm test
```
Finally, commit changes and create a [pull request][8] against the default
branch for review. At a minimum, there should be no test regressions and
additional tests should be added for new functionality.
> [!TIP]
> Code quality checks are enabled for this repository. To run checks manually,
> issue `npm run check`. A Git pre-commit hook is also available to run checks
> automatically, which can be installed by issuing `npm run install-hooks`.
## Making Releases
Making releases is automated by [GitHub Actions][9]. Releases are created from
the default branch; as such, tests should be passing at all times.
To make a release, perform the following in a feature branch:
1. Create a new section in [CHANGELOG.md][7] for the release, and move items
from Unreleased to this section. Links should be updated to point to the
correct tags for comparison.
2. Run `npm version` with the appropriate arguments to bump the version.
3. Commit outstanding changes by issuing:
```
$ git commit -a -m "Release <version>"
```
4. Push changes and open a pull request. Verify the results of the [CI][10]
workflow before merging.
5. Once merged, pull changes and create a release tag from the default branch
branch by issuing:
```
$ git tag -a -m "Release <version>" <version>
```
6. Push the release tag to the remote repository and verify the results of the
[Release][11] workflow:
```
$ git push origin --tags
```
> [!IMPORTANT]
> Release tags must match the version specified in [manifest.json][12]. See the
> [Obsidian Docs][13] for more details.
## License
By contributing to this repository, you agree that your contributions will be
licensed under its Simplified BSD License.
[1]: https://github.com/sstallion/obsidian-command-line/issues
[2]: https://docs.npmjs.com/downloading-and-installing-node-js-and-npm
[3]: https://nodejs.org/en/about/previous-releases
[4]: https://docs.github.com/en/github/getting-started-with-github/fork-a-repo
[5]: https://docs.obsidian.md/Reference/Manifest
[6]: https://github.com/sstallion/obsidian-command-line/blob/master/package.json
[7]: https://github.com/sstallion/obsidian-command-line/blob/master/CHANGELOG.md
[8]: https://docs.github.com/en/github/collaborating-with-issues-and-pull-requests/creating-a-pull-request
[9]: https://docs.github.com/en/actions
[10]: https://github.com/sstallion/obsidian-command-line/actions/workflows/ci.yml
[11]: https://github.com/sstallion/obsidian-command-line/actions/workflows/release.yml
[12]: https://github.com/sstallion/obsidian-command-line/blob/master/manifest.json
[13]: https://docs.obsidian.md/Plugins/Releasing/Submit+your+plugin#Step%202%20Create%20a%20release

22
LICENSE Normal file
View file

@ -0,0 +1,22 @@
Copyright (c) 2025 Steven Stallion <sstallion@gmail.com>
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
SUCH DAMAGE.

61
README.md Normal file
View file

@ -0,0 +1,61 @@
# Obsidian Command Line
[![](https://github.com/sstallion/obsidian-command-line/actions/workflows/ci.yml/badge.svg?branch=master)][1]
[![](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fraw.githubusercontent.com%2Fsstallion%2Fobsidian-command-line%2Frefs%2Fheads%2Fmaster%2Fmanifest.json&query=minAppVersion&label=Obsidian&labelColor=%237c3aed&color=%23121212)][2]
[![](https://img.shields.io/github/v/release/sstallion/obsidian-command-line)][3]
[![](https://img.shields.io/github/license/sstallion/ClipSock.svg)][4]
This plugin adds enhanced support for command line code blocks in Obsidian.
## Features
- Copy command lines from your notes without selecting text.
- Prompts and output are removed from clipboard contents to support pasting into
terminal sessions.
- Default prompts are provided for command lines that do not specify one.
- Optional syntax highlighting and styling using [Prism Command Line][5].
- Optional support for removing unnecessary white space from clipboard contents.
- Supports mobile and desktop.
## Getting Started
Command line code blocks are identified using one of the following languages:
| Name | Highlight | Prompt Pattern | Default Prompt | Continuation |
| -------------------- | ------------ | -------------- | -------------- | ------------ |
| `powershell-command` | `powershell` | `/\S*?>/` | `PS>` | `` ` `` |
| `shell-command` | `shell` | `/\S*?[#$%]/` | `$` | `\` |
| `windows-command` | `batch` | `/\S*?>/` | `C:\>` | `^` |
For example, copying the following code block to the clipboard will remove the
prompt and output while preserving line continuations and white space:
````
```shell-command
$ command with \
line continuation
and output, which will be removed
from the clipboard.
```
````
> [!NOTE]
> If clipboard normalization is enabled in Settings, unnecessary white space
> (including line continuations) will be removed in addition to the prompt and
> output.
## Contributing
Pull requests are welcome! See [CONTRIBUTING.md][6] for details.
## License
Source code in this repository is licensed under a Simplified BSD License. See
[LICENSE][4] for details.
[1]: https://github.com/sstallion/obsidian-command-line/actions/workflows/ci.yml
[2]: https://github.com/sstallion/obsidian-command-line/blob/master/manifest.json
[3]: https://github.com/sstallion/obsidian-command-line/releases/latest
[4]: https://github.com/sstallion/obsidian-command-line/blob/master/LICENSE
[5]: https://prismjs.com/plugins/command-line/
[6]: https://github.com/sstallion/obsidian-command-line/blob/master/CONTRIBUTING.md

52
esbuild.config.mjs Normal file
View file

@ -0,0 +1,52 @@
import { http } from "@hyrious/esbuild-plugin-http";
import esbuild from "esbuild";
import process from "node:process";
const options = {
sourcemap: "inline",
format: "cjs",
target: "es2023",
logLevel: "info",
bundle: true,
outdir: ".",
external: [
"obsidian",
"@codemirror/autocomplete",
"@codemirror/collab",
"@codemirror/commands",
"@codemirror/language",
"@codemirror/lint",
"@codemirror/search",
"@codemirror/state",
"@codemirror/view",
"@lezer/common",
"@lezer/highlight",
"@lezer/lr",
],
entryPoints: ["src/main.ts", "src/styles.css"],
plugins: [http()],
};
const args = process.argv.slice(2);
switch (args[0]) {
case "release": {
Object.assign(options, {
sourcemap: false,
minify: true,
treeShaking: true,
});
// fallthrough
}
case "build": {
await esbuild.build(options);
break;
}
case "watch": {
const context = await esbuild.context(options);
await context.watch();
break;
}
default: {
throw new Error(`Unknown command: ${args[0]}`);
}
}

25
eslint.config.mjs Normal file
View file

@ -0,0 +1,25 @@
import eslint from "@eslint/js";
import gitignore from "eslint-config-flat-gitignore";
import tseslint from "typescript-eslint";
export default tseslint.config(
gitignore(),
eslint.configs.recommended,
tseslint.configs.recommendedTypeChecked,
tseslint.configs.stylisticTypeChecked,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
"@typescript-eslint/ban-ts-comment": "off",
},
},
{
files: ["**/*.mjs"],
extends: [tseslint.configs.disableTypeChecked],
},
);

10
manifest.json Normal file
View file

@ -0,0 +1,10 @@
{
"id": "command-line",
"name": "Command Line",
"version": "0.0.0",
"minAppVersion": "1.8.0",
"description": "Copy command lines from your notes to the clipboard.",
"author": "Steven Stallion",
"authorUrl": "https://github.com/sstallion",
"isDesktopOnly": false
}

4922
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

37
package.json Normal file
View file

@ -0,0 +1,37 @@
{
"name": "obsidian-command-line",
"version": "0.0.0",
"license": "BSD-2-Clause",
"scripts": {
"install-hooks": "simple-git-hooks",
"build": "node esbuild.config.mjs build",
"release": "node esbuild.config.mjs release",
"watch": "node esbuild.config.mjs watch",
"test": "vitest",
"lint": "eslint",
"format": "prettier --write .",
"check": "tsc --noEmit && eslint --max-warnings 0 && prettier --check .",
"version": "node version.config.mjs"
},
"devDependencies": {
"@hyrious/esbuild-plugin-http": "^0.1.0",
"@tsconfig/node20": "^20.0.0",
"@types/prismjs": "^1.0.0",
"editorconfig": "^2.0.0",
"esbuild": "^0.25.0",
"eslint": "^9.0.0",
"eslint-config-flat-gitignore": "^2.0.0",
"jsdom": "^26.0.0",
"obsidian": "latest",
"prettier": "latest",
"prettier-plugin-organize-imports": "latest",
"simple-git-hooks": "latest",
"tslib": "latest",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0",
"vitest": "^3.0.0"
},
"engines": {
"node": "20 >=20.11.0 || 21 >=21.2.0 || >=22.0.0"
}
}

3
prettier.config.mjs Normal file
View file

@ -0,0 +1,3 @@
export default {
plugins: ["prettier-plugin-organize-imports"],
};

3
simple-git-hooks.json Normal file
View file

@ -0,0 +1,3 @@
{
"pre-commit": "npm run check"
}

144
src/command.ts Normal file
View file

@ -0,0 +1,144 @@
/*
* Copyright (c) 2025 Steven Stallion <sstallion@gmail.com>
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
import * as util from "./util";
const CONTINUATION_PROMPT = " ";
const CONTINUATION_PREFIX = "(con)";
const OUTPUT_PREFIX = "(out)";
export interface CommandLineContext {
defaultPrompt: string;
promptPattern: string;
continuationPattern: string;
alias?: string;
}
export class CommandLine {
private readonly ctx: CommandLineContext;
prompt: string | undefined;
command: string[];
output: string[];
constructor(
ctx: CommandLineContext,
prompt: string | undefined,
command: string[],
output: string[],
) {
this.ctx = ctx;
this.prompt = prompt;
this.command = command;
this.output = output;
}
isMultiline(): boolean {
return this.command.length > 0;
}
hasOutput(): boolean {
return this.output.length > 0;
}
highlight(codeEl: HTMLElement) {
const preEl = codeEl.parentElement!;
preEl.addClass("command-line");
preEl.setAttrs({
"data-prompt": this.prompt ?? this.ctx.defaultPrompt,
"data-continuation-prompt": CONTINUATION_PROMPT,
"data-filter-continuation": CONTINUATION_PREFIX,
"data-filter-output": OUTPUT_PREFIX,
});
codeEl.setText(this.toCode());
}
normalize(): string {
const re = new RegExp(this.ctx.continuationPattern);
return this.command.map((line) => line.replace(re, "").trim()).join(" ");
}
toCode(): string {
return util
.mapTail(this.command, (line) => CONTINUATION_PREFIX + line)
.concat(this.output.map((line) => OUTPUT_PREFIX + line))
.join("\n");
}
toString(): string {
return this.command.join("\n");
}
}
const enum CommandLineParserState {
Prompt,
Command,
Output,
}
export class CommandLineParser {
private readonly ctx: CommandLineContext;
private readonly re: Record<string, RegExp>;
constructor(ctx: CommandLineContext) {
this.ctx = ctx;
this.re = {
prompt: new RegExp(ctx.promptPattern),
continuation: new RegExp(ctx.continuationPattern),
};
}
parse(text: string): CommandLine {
let line, prompt;
const command = [];
const output = [];
let state = CommandLineParserState.Prompt;
const lines = text.trim().split("\n");
while ((line = lines.shift())) {
switch (state) {
case CommandLineParserState.Prompt: {
const match = line.match(this.re.prompt);
if (match) {
prompt = match[0].trim();
line = line.replace(this.re.prompt, "");
}
state = CommandLineParserState.Command;
// fallthrough
}
case CommandLineParserState.Command: {
command.push(line);
if (!line.match(this.re.continuation)) {
state = CommandLineParserState.Output;
}
break;
}
case CommandLineParserState.Output: {
output.push(line);
break;
}
}
}
return new CommandLine(this.ctx, prompt, command, output);
}
}

160
src/main.ts Normal file
View file

@ -0,0 +1,160 @@
/*
* Copyright (c) 2025 Steven Stallion <sstallion@gmail.com>
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
import { ButtonComponent, Plugin } from "obsidian";
import { CommandLine, CommandLineParser } from "./command";
import { CommandLineSettings, CommandLineSettingsTab } from "./settings";
import * as util from "./util";
const COPY_BUTTON_DELAY = 1000;
const DEFAULT_SETTINGS: Partial<CommandLineSettings> = {
highlight: false,
normalize: false,
languages: {
"powershell-command": {
defaultPrompt: "PS>",
promptPattern: "^\\S*?>\\s*",
continuationPattern: "`$",
alias: "powershell",
},
"shell-command": {
defaultPrompt: "$",
promptPattern: "^\\S*?[#$%]\\s*",
continuationPattern: "\\\\$",
alias: "shell",
},
"windows-command": {
defaultPrompt: "C:\\>",
promptPattern: "^\\S*?>\\s*",
continuationPattern: "\\^$",
alias: "batch",
},
},
};
export default class CommandLinePlugin extends Plugin {
// @ts-ignore: Initialized by onload()
settings: CommandLineSettings;
addCopyButton(containerEl: HTMLElement, command: CommandLine): ButtonComponent {
return new ButtonComponent(containerEl)
.setIcon("lucide-copy")
.setClass("copy-code-button")
.then((button) => {
button.onClick(async (evt) => {
evt.preventDefault();
await navigator.clipboard.writeText(
this.settings.normalize
? command.normalize()
: command.toString(),
);
button // prettier-ignore
.setIcon("lucide-check")
.buttonEl.setCssStyles({
color: "var(--text-success)",
display: "inline-flex",
});
setTimeout(() => {
button // prettier-ignore
.setIcon("lucide-copy")
.buttonEl.setCssStyles({
color: "",
display: "",
});
}, COPY_BUTTON_DELAY);
});
});
}
async loadSettings() {
const settings = (await this.loadData()) as CommandLineSettings;
this.settings = Object.assign({}, DEFAULT_SETTINGS, settings);
}
async saveSettings() {
await this.saveData(this.settings);
}
async onExternalSettingsChange() {
await this.loadSettings();
location.reload();
}
async onload() {
await this.loadSettings();
this.addSettingTab(new CommandLineSettingsTab(this));
// Obsidian loads Prism asynchronously, however it does not bundle
// additional plugins. A deferred dynamic import is needed to avoid a
// race when registering plugins. Care must be taken to import plugins
// from a compatible version of Prism for a given Obsidian release.
if (this.settings.highlight) {
this.app.workspace.onLayoutReady(async () => {
await util.waitForGlobal("Prism").then(async () => {
await import(
// @ts-ignore: Bundled by esbuild
"https://unpkg.com/prismjs@v1.29.0/plugins/command-line/prism-command-line.js"
);
const entries = Object.entries(this.settings.languages);
for (const [language, ctx] of entries) {
if (ctx?.alias) {
Prism.languages[language] = Prism.languages[ctx.alias];
}
Prism.languages[language] ??= {};
}
Prism.highlightAll();
});
});
}
this.registerMarkdownPostProcessor((el) => {
for (const codeEl of el.findAll("pre > code")) {
const language = util.getLanguage(codeEl);
if (!(language in this.settings.languages)) {
continue; // unsupported language
}
const ctx = this.settings.languages[language];
const command = new CommandLineParser(ctx).parse(codeEl.getText());
if (this.settings.highlight) {
command.highlight(codeEl);
}
// Due to the order in which builtin post processors are
// registered, there does not appear to be a better method of
// modifying clipboard behavior. Rather than fight event
// handling, we replace the copy button a facsimile.
const preEl = codeEl.parentElement!;
const buttonEl = preEl.find(".copy-code-button");
if (buttonEl) {
const button = this.addCopyButton(preEl, command);
buttonEl.replaceWith(button.buttonEl);
}
}
});
}
}

83
src/settings.ts Normal file
View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2025 Steven Stallion <sstallion@gmail.com>
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
import { PluginSettingTab, Setting } from "obsidian";
import { CommandLineContext } from "./command";
import CommandLinePlugin from "./main";
import * as util from "./util";
const RELOAD_DELAY = 500;
export interface CommandLineSettings {
highlight: boolean;
normalize: boolean;
languages: Record<string, CommandLineContext>;
}
export class CommandLineSettingsTab extends PluginSettingTab {
readonly plugin: CommandLinePlugin;
constructor(plugin: CommandLinePlugin) {
super(plugin.app, plugin);
this.plugin = plugin;
}
display() {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl)
.setName("Highlight command lines")
.setDesc("Enable highlighting and additional styling for command lines.")
.then(({ descEl }) => {
descEl
.createDiv("mod-warning")
.setText("Changing this option will reload the app.");
})
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.highlight)
.onChange(async (value) => {
this.plugin.settings.highlight = value;
await this.plugin.saveSettings();
await util.sleep(RELOAD_DELAY).then(() => {
location.reload();
});
});
});
new Setting(containerEl)
.setName("Normalize clipboard contents")
.setDesc("Remove uneccessary white space when copying to the clipboard.")
.addToggle((toggle) => {
toggle
.setValue(this.plugin.settings.normalize)
.onChange(async (value) => {
this.plugin.settings.normalize = value;
await this.plugin.saveSettings();
});
});
}
}

26
src/styles.css Normal file
View file

@ -0,0 +1,26 @@
/*
* Copyright (c) 2025 Steven Stallion <sstallion@gmail.com>
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
@import "https://unpkg.com/prismjs@v1.29.0/plugins/command-line/prism-command-line.css";

61
src/util.ts Normal file
View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2025 Steven Stallion <sstallion@gmail.com>
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
export function getLanguage(el: HTMLElement): string {
for (const cls of el.classList) {
const result = /^language-(.*)$/.exec(cls);
if (result) {
return result[1];
}
}
return "";
}
export function mapTail<T>(array: T[], callbackFn: (thisArg: T) => T): T[] {
return array.reduce((accumulator, currentValue) => {
if (accumulator.length == 0) {
accumulator.push(currentValue);
} else {
accumulator.push(callbackFn(currentValue));
}
return accumulator;
}, [] as T[]);
}
export function sleep(delay: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, delay));
}
export function waitForGlobal(name: string, delay = 100): Promise<void> {
return new Promise((resolve) => {
(function resolver() {
if (name in window) {
resolve();
} else {
setTimeout(resolver, delay);
}
})();
});
}

79
tests/command.test.ts Normal file
View file

@ -0,0 +1,79 @@
/*
* Copyright (c) 2025 Steven Stallion <sstallion@gmail.com>
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
import { test as base, expect } from "vitest";
import { CommandLineParser } from "../src/command";
const test = base.extend({
parser: new CommandLineParser({
defaultPrompt: "$",
promptPattern: "^\\$\\s*",
continuationPattern: "\\\\$",
}),
});
test("parse empty string", ({ parser }) => {
const actual = parser.parse("");
expect(actual.toString()).toBe("");
});
test("parse with prompt", ({ parser }) => {
const actual = parser.parse("$ command with prompt");
expect(actual.toString()).toBe("command with prompt");
});
test("parse with continuations", ({ parser }) => {
const actual = parser.parse("command \\\nwith \\\ncontinuations");
expect(actual.toString()).toBe("command \\\nwith \\\ncontinuations");
});
test("parse with output", ({ parser }) => {
const actual = parser.parse("command\nwith\noutput");
expect(actual.toString()).toBe("command"); // with output
});
test("normalize with continuations", ({ parser }) => {
const actual = parser.parse("command \\\nwith \\\ncontinuations");
expect(actual.normalize()).toBe("command with continuations");
});
test("normalize with inner white space", ({ parser }) => {
const actual = parser.parse("command with inner white space preserved");
expect(actual.normalize()).toBe("command with inner white space preserved");
});
test("normalize with outer white space", ({ parser }) => {
const actual = parser.parse(" command with outer white space removed ");
expect(actual.normalize()).toBe("command with outer white space removed");
});
test("normalize the kitchen sink", ({ parser }) => {
const actual = parser.parse(`$ command with \\
entirely too \\
much formatting
and a couple lines
of output\n`);
expect(actual.normalize()).toBe("command with entirely too much formatting");
});

66
tests/util.test.ts Normal file
View file

@ -0,0 +1,66 @@
/*
* Copyright (c) 2025 Steven Stallion <sstallion@gmail.com>
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
* 1. Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
* FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
* DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
* OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
* HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
* OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
* SUCH DAMAGE.
*/
import { describe, expect, test, vi } from "vitest";
import * as util from "../src/util";
describe("getLanguage()", () => {
test("defaults to empty string", () => {
const codeEl = document.createElement("code");
expect(util.getLanguage(codeEl)).toBe("");
});
test("returns language", () => {
const codeEl = document.createElement("code");
codeEl.classList.add("language-test");
expect(util.getLanguage(codeEl)).toBe("test");
});
test("returns first language in list", () => {
const codeEl = document.createElement("code");
for (const name of ["first", "next", "last"]) {
codeEl.classList.add(`language-${name}`);
}
expect(util.getLanguage(codeEl)).toBe("first");
});
});
describe("mapTail()", () => {
test("defaults to empty array", () => {
expect(util.mapTail([], vi.fn())).toStrictEqual([]);
});
test("accumulates head and tail elements", () => {
expect(
util.mapTail(["head", "", ""], (value) => value + "tail"),
).toStrictEqual(["head", "tail", "tail"]);
});
test("skips callbacks without tail elements", () => {
const callbackFn = vi.fn();
util.mapTail(["head"], callbackFn);
expect(callbackFn).not.toHaveBeenCalled();
});
});

9
tsconfig.json Normal file
View file

@ -0,0 +1,9 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"compilerOptions": {
"allowUmdGlobalAccess": true,
"exactOptionalPropertyTypes": true,
"lib": ["dom", "dom.iterable", "es2023"]
},
"include": ["src/**/*", "tests/**/*"]
}

26
version.config.mjs Normal file
View file

@ -0,0 +1,26 @@
import editorconfig from "editorconfig";
import { readFileSync, writeFileSync } from "node:fs";
import { env } from "node:process";
// Update manifest.json and versions.json based on the package version.
// EditorConfig rules are applied to ensure consistent formatting.
const version = env.npm_package_version;
function readJSON(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function writeJSON(path, value) {
const config = editorconfig.parseSync(path);
const space = config.indent_style === "tab" ? "\t" : (config.indent_size ?? 2);
const newline = config.insert_final_newline ? "\n" : "";
writeFileSync(path, JSON.stringify(value, null, space) + newline);
}
const manifest = readJSON("manifest.json");
manifest.version = version;
writeJSON("manifest.json", manifest);
const versions = readJSON("versions.json");
versions[version] = manifest.minAppVersion;
writeJSON("versions.json", versions);

1
versions.json Normal file
View file

@ -0,0 +1 @@
{}

7
vitest.config.mjs Normal file
View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
},
});