mirror of
https://github.com/keathmilligan/obsidian-paste-reformatter.git
synced 2026-07-22 12:20:26 +00:00
Compare commits
No commits in common. "main" and "1.3.0" have entirely different histories.
21 changed files with 1867 additions and 3205 deletions
28
.github/workflows/ci.yml
vendored
28
.github/workflows/ci.yml
vendored
|
|
@ -1,28 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags-ignore:
|
||||
- "*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build plugin
|
||||
run: npm run build
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -32,4 +32,4 @@ jobs:
|
|||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
main.js manifest.json styles.css
|
||||
dist/main.js manifest.json styles.css
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -21,6 +21,11 @@ dist/
|
|||
dist
|
||||
build/
|
||||
|
||||
# Dependency lock files (keep package.json)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
|
|
|||
21
AGENTS.md
21
AGENTS.md
|
|
@ -1,3 +1,22 @@
|
|||
<!-- OPENSPEC:START -->
|
||||
# OpenSpec Instructions
|
||||
|
||||
These instructions are for AI assistants working in this project.
|
||||
|
||||
Always open `@/openspec/AGENTS.md` when the request:
|
||||
- Mentions planning or proposals (words like proposal, spec, change, plan)
|
||||
- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work
|
||||
- Sounds ambiguous and you need the authoritative spec before coding
|
||||
|
||||
Use `@/openspec/AGENTS.md` to learn:
|
||||
- How to create and apply change proposals
|
||||
- Spec format and conventions
|
||||
- Project structure and guidelines
|
||||
|
||||
Keep this managed block so 'openspec update' can refresh the instructions.
|
||||
|
||||
<!-- OPENSPEC:END -->
|
||||
|
||||
# Obsidian Plugin Development Guidelines
|
||||
|
||||
This is an Obsidian plugin project. Follow these guidelines when working with the codebase.
|
||||
|
|
@ -70,4 +89,4 @@ This is an Obsidian plugin project. Follow these guidelines when working with th
|
|||
- Avoid blocking the main thread
|
||||
- Use debouncing for frequent operations
|
||||
- Be mindful of large vault operations
|
||||
- Cache expensive computations when appropriate
|
||||
- Cache expensive computations when appropriate
|
||||
|
|
@ -36,7 +36,7 @@ The Paste Reformatter plugin automatically processes content when you paste it i
|
|||
4. Applies Markdown transformations (heading adjustments, line break handling, regex string replacement)
|
||||
5. Inserts the transformed content at the cursor position
|
||||
|
||||
A notification will appear briefly indicating whether HTML or plain text content was reformatted. This can be disabled in settings.
|
||||
A notification will appear briefly indicating whether HTML or plain text content was reformatted.
|
||||
|
||||
### Commands
|
||||
|
||||
|
|
@ -62,10 +62,6 @@ Paste Reformatter can potentially conflict with other Obsidian plugins that over
|
|||
When this setting is enabled, the default behavior of Obsidian's Paste function will be enhanced. Otherwise,
|
||||
**Reformat and Paste** command can be bound to an alternative hot-key to get enhanced paste behavior.
|
||||
|
||||
#### Show paste notifications
|
||||
|
||||
When enabled, a notice appears after reformatting content. Disable this to hide notifications.
|
||||
|
||||
### HTML Transformations
|
||||
|
||||
These settings control how HTML content is processed before being converted to Markdown.
|
||||
|
|
|
|||
158
cascade-rules.md
Normal file
158
cascade-rules.md
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
# Heading Cascade Rules
|
||||
|
||||
## Scanario: Cascade Heading Levels is Disabled
|
||||
|
||||
When Cascade Heading Levels is not enabled, then the Max Heading Level setting simply caps the maximum heading value that is allowed. For instance, if it is set to H3, then H1 and H2 would simply be changed to H3. Otherwise, heading values are not affected.
|
||||
|
||||
### Example
|
||||
|
||||
If Max heading Level is set to H3, then the following text when pasted:
|
||||
|
||||
```
|
||||
# Heading 1
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
|
||||
# Heading 1
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
```
|
||||
|
||||
Will be transformed into:
|
||||
|
||||
```
|
||||
### Heading 1
|
||||
### Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
|
||||
### Heading 1
|
||||
### Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
```
|
||||
|
||||
## Scenario: Cascade Heading Levels is Enabled
|
||||
|
||||
When Cascade Heading Levels is enabled, the heading values that are greater (in this case "greater" means H1 is greater than H2, H2 is greater than H3, etc.) than the max heading level setting are changed to the max heading level.
|
||||
In addition subsequent headings values are demoted ("cascaded") to the heading level below.
|
||||
|
||||
### Example
|
||||
|
||||
When max heading level is set to H3 and the following text was pasted:
|
||||
|
||||
```
|
||||
# Heading 1
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
|
||||
# Heading 1
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
```
|
||||
|
||||
The result would be:
|
||||
|
||||
```
|
||||
### Heading 1
|
||||
#### Heading 2
|
||||
##### Heading 3
|
||||
###### Heading 4
|
||||
###### Heading 5
|
||||
###### Heading 6
|
||||
|
||||
### Heading 1
|
||||
#### Heading 2
|
||||
##### Heading 3
|
||||
###### Heading 4
|
||||
###### Heading 5
|
||||
###### Heading 6
|
||||
```
|
||||
|
||||
(heading levels will never be demoted below H6, e.g. "######")
|
||||
|
||||
### Another Example
|
||||
|
||||
When Max Heading Level is set to H2. The following should remain unchanged:
|
||||
|
||||
```
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
```
|
||||
|
||||
# Contextual Cascade Rules
|
||||
|
||||
Contextual Cascade is completely independent of Cascade Heading Levels.
|
||||
|
||||
## Scenario: Contextual Cascade is Disabled
|
||||
|
||||
When Contextual Cascade is disabled, then the normal Max Heading Level and Cascade Heading Levels rules apply as usual.
|
||||
|
||||
## Scenario: Contextual Cascade is Enabled
|
||||
|
||||
When Contextual Cascade is enabled, then the Max Heading Level and Cascade Heading Levels rules are superceded by the Contextual Cascade rules:
|
||||
|
||||
### Example
|
||||
|
||||
When text is pasted into an H2 section, for example, then headings are cascaded down from H3. For example, if the following text was pasted into an H2 section:
|
||||
|
||||
```
|
||||
# Heading 1
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
|
||||
# Heading 1
|
||||
## Heading 2
|
||||
### Heading 3
|
||||
#### Heading 4
|
||||
##### Heading 5
|
||||
###### Heading 6
|
||||
```
|
||||
|
||||
The result would be:
|
||||
|
||||
```
|
||||
### Heading 1
|
||||
#### Heading 2
|
||||
##### Heading 3
|
||||
###### Heading 4
|
||||
###### Heading 5
|
||||
###### Heading 6
|
||||
|
||||
### Heading 1
|
||||
#### Heading 2
|
||||
##### Heading 3
|
||||
###### Heading 4
|
||||
###### Heading 5
|
||||
###### Heading 6
|
||||
```
|
||||
|
||||
As with regular cascading, the heading levels will never be demoted below H6, e.g. "######".
|
||||
34
copy-styles.mjs
Normal file
34
copy-styles.mjs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// copy-styles.mjs
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Ensure the dist directory exists
|
||||
async function ensureDir(dir) {
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
} catch (error) {
|
||||
if (error.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFiles() {
|
||||
const distDir = path.resolve('./dist');
|
||||
await ensureDir(distDir);
|
||||
|
||||
try {
|
||||
// Copy styles.css to dist directory
|
||||
await fs.copyFile('./styles.css', path.join(distDir, 'styles.css'));
|
||||
console.log('Successfully copied styles.css to dist directory');
|
||||
|
||||
// Copy manifest.json to dist directory
|
||||
await fs.copyFile('./manifest.json', path.join(distDir, 'manifest.json'));
|
||||
console.log('Successfully copied manifest.json to dist directory');
|
||||
} catch (error) {
|
||||
console.error('Error copying files:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
copyFiles();
|
||||
|
|
@ -3,7 +3,7 @@ import process from "process";
|
|||
import builtins from "builtin-modules";
|
||||
|
||||
const banner =
|
||||
`/*
|
||||
`/*
|
||||
THIS IS A GENERATED/BUNDLED FILE BY ESBUILD
|
||||
if you want to view the source, please visit the github repository of this plugin
|
||||
*/
|
||||
|
|
@ -12,38 +12,38 @@ if you want to view the source, please visit the github repository of this plugi
|
|||
const prod = (process.argv[2] === "production");
|
||||
|
||||
const context = await esbuild.context({
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "main.js",
|
||||
minify: prod,
|
||||
banner: {
|
||||
js: banner,
|
||||
},
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: [
|
||||
"obsidian",
|
||||
"electron",
|
||||
"@codemirror/autocomplete",
|
||||
"@codemirror/collab",
|
||||
"@codemirror/commands",
|
||||
"@codemirror/language",
|
||||
"@codemirror/lint",
|
||||
"@codemirror/search",
|
||||
"@codemirror/state",
|
||||
"@codemirror/view",
|
||||
"@lezer/common",
|
||||
"@lezer/highlight",
|
||||
"@lezer/lr",
|
||||
...builtins],
|
||||
format: "cjs",
|
||||
target: "es2018",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
outfile: "dist/main.js",
|
||||
minify: prod,
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
await context.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await context.watch();
|
||||
await context.watch();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "paste-reformatter",
|
||||
"name": "Paste Reformatter",
|
||||
"version": "1.4.2",
|
||||
"version": "1.3.0",
|
||||
"minAppVersion": "0.15.0",
|
||||
"description": "Reformat pasted text for precise control.",
|
||||
"author": "Keath Milligan",
|
||||
|
|
|
|||
456
openspec/AGENTS.md
Normal file
456
openspec/AGENTS.md
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
# OpenSpec Instructions
|
||||
|
||||
Instructions for AI coding assistants using OpenSpec for spec-driven development.
|
||||
|
||||
## TL;DR Quick Checklist
|
||||
|
||||
- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search)
|
||||
- Decide scope: new capability vs modify existing capability
|
||||
- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`)
|
||||
- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability
|
||||
- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement
|
||||
- Validate: `openspec validate [change-id] --strict` and fix issues
|
||||
- Request approval: Do not start implementation until proposal is approved
|
||||
|
||||
## Three-Stage Workflow
|
||||
|
||||
### Stage 1: Creating Changes
|
||||
Create proposal when you need to:
|
||||
- Add features or functionality
|
||||
- Make breaking changes (API, schema)
|
||||
- Change architecture or patterns
|
||||
- Optimize performance (changes behavior)
|
||||
- Update security patterns
|
||||
|
||||
Triggers (examples):
|
||||
- "Help me create a change proposal"
|
||||
- "Help me plan a change"
|
||||
- "Help me create a proposal"
|
||||
- "I want to create a spec proposal"
|
||||
- "I want to create a spec"
|
||||
|
||||
Loose matching guidance:
|
||||
- Contains one of: `proposal`, `change`, `spec`
|
||||
- With one of: `create`, `plan`, `make`, `start`, `help`
|
||||
|
||||
Skip proposal for:
|
||||
- Bug fixes (restore intended behavior)
|
||||
- Typos, formatting, comments
|
||||
- Dependency updates (non-breaking)
|
||||
- Configuration changes
|
||||
- Tests for existing behavior
|
||||
|
||||
**Workflow**
|
||||
1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context.
|
||||
2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes/<id>/`.
|
||||
3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement.
|
||||
4. Run `openspec validate <id> --strict` and resolve any issues before sharing the proposal.
|
||||
|
||||
### Stage 2: Implementing Changes
|
||||
Track these steps as TODOs and complete them one by one.
|
||||
1. **Read proposal.md** - Understand what's being built
|
||||
2. **Read design.md** (if exists) - Review technical decisions
|
||||
3. **Read tasks.md** - Get implementation checklist
|
||||
4. **Implement tasks sequentially** - Complete in order
|
||||
5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses
|
||||
6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality
|
||||
7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved
|
||||
|
||||
### Stage 3: Archiving Changes
|
||||
After deployment, create separate PR to:
|
||||
- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/`
|
||||
- Update `specs/` if capabilities changed
|
||||
- Use `openspec archive <change-id> --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly)
|
||||
- Run `openspec validate --strict` to confirm the archived change passes checks
|
||||
|
||||
## Before Any Task
|
||||
|
||||
**Context Checklist:**
|
||||
- [ ] Read relevant specs in `specs/[capability]/spec.md`
|
||||
- [ ] Check pending changes in `changes/` for conflicts
|
||||
- [ ] Read `openspec/project.md` for conventions
|
||||
- [ ] Run `openspec list` to see active changes
|
||||
- [ ] Run `openspec list --specs` to see existing capabilities
|
||||
|
||||
**Before Creating Specs:**
|
||||
- Always check if capability already exists
|
||||
- Prefer modifying existing specs over creating duplicates
|
||||
- Use `openspec show [spec]` to review current state
|
||||
- If request is ambiguous, ask 1–2 clarifying questions before scaffolding
|
||||
|
||||
### Search Guidance
|
||||
- Enumerate specs: `openspec spec list --long` (or `--json` for scripts)
|
||||
- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available)
|
||||
- Show details:
|
||||
- Spec: `openspec show <spec-id> --type spec` (use `--json` for filters)
|
||||
- Change: `openspec show <change-id> --json --deltas-only`
|
||||
- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs`
|
||||
|
||||
## Quick Start
|
||||
|
||||
### CLI Commands
|
||||
|
||||
```bash
|
||||
# Essential commands
|
||||
openspec list # List active changes
|
||||
openspec list --specs # List specifications
|
||||
openspec show [item] # Display change or spec
|
||||
openspec validate [item] # Validate changes or specs
|
||||
openspec archive <change-id> [--yes|-y] # Archive after deployment (add --yes for non-interactive runs)
|
||||
|
||||
# Project management
|
||||
openspec init [path] # Initialize OpenSpec
|
||||
openspec update [path] # Update instruction files
|
||||
|
||||
# Interactive mode
|
||||
openspec show # Prompts for selection
|
||||
openspec validate # Bulk validation mode
|
||||
|
||||
# Debugging
|
||||
openspec show [change] --json --deltas-only
|
||||
openspec validate [change] --strict
|
||||
```
|
||||
|
||||
### Command Flags
|
||||
|
||||
- `--json` - Machine-readable output
|
||||
- `--type change|spec` - Disambiguate items
|
||||
- `--strict` - Comprehensive validation
|
||||
- `--no-interactive` - Disable prompts
|
||||
- `--skip-specs` - Archive without spec updates
|
||||
- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive)
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
openspec/
|
||||
├── project.md # Project conventions
|
||||
├── specs/ # Current truth - what IS built
|
||||
│ └── [capability]/ # Single focused capability
|
||||
│ ├── spec.md # Requirements and scenarios
|
||||
│ └── design.md # Technical patterns
|
||||
├── changes/ # Proposals - what SHOULD change
|
||||
│ ├── [change-name]/
|
||||
│ │ ├── proposal.md # Why, what, impact
|
||||
│ │ ├── tasks.md # Implementation checklist
|
||||
│ │ ├── design.md # Technical decisions (optional; see criteria)
|
||||
│ │ └── specs/ # Delta changes
|
||||
│ │ └── [capability]/
|
||||
│ │ └── spec.md # ADDED/MODIFIED/REMOVED
|
||||
│ └── archive/ # Completed changes
|
||||
```
|
||||
|
||||
## Creating Change Proposals
|
||||
|
||||
### Decision Tree
|
||||
|
||||
```
|
||||
New request?
|
||||
├─ Bug fix restoring spec behavior? → Fix directly
|
||||
├─ Typo/format/comment? → Fix directly
|
||||
├─ New feature/capability? → Create proposal
|
||||
├─ Breaking change? → Create proposal
|
||||
├─ Architecture change? → Create proposal
|
||||
└─ Unclear? → Create proposal (safer)
|
||||
```
|
||||
|
||||
### Proposal Structure
|
||||
|
||||
1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique)
|
||||
|
||||
2. **Write proposal.md:**
|
||||
```markdown
|
||||
# Change: [Brief description of change]
|
||||
|
||||
## Why
|
||||
[1-2 sentences on problem/opportunity]
|
||||
|
||||
## What Changes
|
||||
- [Bullet list of changes]
|
||||
- [Mark breaking changes with **BREAKING**]
|
||||
|
||||
## Impact
|
||||
- Affected specs: [list capabilities]
|
||||
- Affected code: [key files/systems]
|
||||
```
|
||||
|
||||
3. **Create spec deltas:** `specs/[capability]/spec.md`
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: New Feature
|
||||
The system SHALL provide...
|
||||
|
||||
#### Scenario: Success case
|
||||
- **WHEN** user performs action
|
||||
- **THEN** expected result
|
||||
|
||||
## MODIFIED Requirements
|
||||
### Requirement: Existing Feature
|
||||
[Complete modified requirement]
|
||||
|
||||
## REMOVED Requirements
|
||||
### Requirement: Old Feature
|
||||
**Reason**: [Why removing]
|
||||
**Migration**: [How to handle]
|
||||
```
|
||||
If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs/<capability>/spec.md`—one per capability.
|
||||
|
||||
4. **Create tasks.md:**
|
||||
```markdown
|
||||
## 1. Implementation
|
||||
- [ ] 1.1 Create database schema
|
||||
- [ ] 1.2 Implement API endpoint
|
||||
- [ ] 1.3 Add frontend component
|
||||
- [ ] 1.4 Write tests
|
||||
```
|
||||
|
||||
5. **Create design.md when needed:**
|
||||
Create `design.md` if any of the following apply; otherwise omit it:
|
||||
- Cross-cutting change (multiple services/modules) or a new architectural pattern
|
||||
- New external dependency or significant data model changes
|
||||
- Security, performance, or migration complexity
|
||||
- Ambiguity that benefits from technical decisions before coding
|
||||
|
||||
Minimal `design.md` skeleton:
|
||||
```markdown
|
||||
## Context
|
||||
[Background, constraints, stakeholders]
|
||||
|
||||
## Goals / Non-Goals
|
||||
- Goals: [...]
|
||||
- Non-Goals: [...]
|
||||
|
||||
## Decisions
|
||||
- Decision: [What and why]
|
||||
- Alternatives considered: [Options + rationale]
|
||||
|
||||
## Risks / Trade-offs
|
||||
- [Risk] → Mitigation
|
||||
|
||||
## Migration Plan
|
||||
[Steps, rollback]
|
||||
|
||||
## Open Questions
|
||||
- [...]
|
||||
```
|
||||
|
||||
## Spec File Format
|
||||
|
||||
### Critical: Scenario Formatting
|
||||
|
||||
**CORRECT** (use #### headers):
|
||||
```markdown
|
||||
#### Scenario: User login success
|
||||
- **WHEN** valid credentials provided
|
||||
- **THEN** return JWT token
|
||||
```
|
||||
|
||||
**WRONG** (don't use bullets or bold):
|
||||
```markdown
|
||||
- **Scenario: User login** ❌
|
||||
**Scenario**: User login ❌
|
||||
### Scenario: User login ❌
|
||||
```
|
||||
|
||||
Every requirement MUST have at least one scenario.
|
||||
|
||||
### Requirement Wording
|
||||
- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative)
|
||||
|
||||
### Delta Operations
|
||||
|
||||
- `## ADDED Requirements` - New capabilities
|
||||
- `## MODIFIED Requirements` - Changed behavior
|
||||
- `## REMOVED Requirements` - Deprecated features
|
||||
- `## RENAMED Requirements` - Name changes
|
||||
|
||||
Headers matched with `trim(header)` - whitespace ignored.
|
||||
|
||||
#### When to use ADDED vs MODIFIED
|
||||
- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement.
|
||||
- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details.
|
||||
- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name.
|
||||
|
||||
Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead.
|
||||
|
||||
Authoring a MODIFIED requirement correctly:
|
||||
1) Locate the existing requirement in `openspec/specs/<capability>/spec.md`.
|
||||
2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios).
|
||||
3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior.
|
||||
4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`.
|
||||
|
||||
Example for RENAMED:
|
||||
```markdown
|
||||
## RENAMED Requirements
|
||||
- FROM: `### Requirement: Login`
|
||||
- TO: `### Requirement: User Authentication`
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Errors
|
||||
|
||||
**"Change must have at least one delta"**
|
||||
- Check `changes/[name]/specs/` exists with .md files
|
||||
- Verify files have operation prefixes (## ADDED Requirements)
|
||||
|
||||
**"Requirement must have at least one scenario"**
|
||||
- Check scenarios use `#### Scenario:` format (4 hashtags)
|
||||
- Don't use bullet points or bold for scenario headers
|
||||
|
||||
**Silent scenario parsing failures**
|
||||
- Exact format required: `#### Scenario: Name`
|
||||
- Debug with: `openspec show [change] --json --deltas-only`
|
||||
|
||||
### Validation Tips
|
||||
|
||||
```bash
|
||||
# Always use strict mode for comprehensive checks
|
||||
openspec validate [change] --strict
|
||||
|
||||
# Debug delta parsing
|
||||
openspec show [change] --json | jq '.deltas'
|
||||
|
||||
# Check specific requirement
|
||||
openspec show [spec] --json -r 1
|
||||
```
|
||||
|
||||
## Happy Path Script
|
||||
|
||||
```bash
|
||||
# 1) Explore current state
|
||||
openspec spec list --long
|
||||
openspec list
|
||||
# Optional full-text search:
|
||||
# rg -n "Requirement:|Scenario:" openspec/specs
|
||||
# rg -n "^#|Requirement:" openspec/changes
|
||||
|
||||
# 2) Choose change id and scaffold
|
||||
CHANGE=add-two-factor-auth
|
||||
mkdir -p openspec/changes/$CHANGE/{specs/auth}
|
||||
printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md
|
||||
printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md
|
||||
|
||||
# 3) Add deltas (example)
|
||||
cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF'
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
Users MUST provide a second factor during login.
|
||||
|
||||
#### Scenario: OTP required
|
||||
- **WHEN** valid credentials are provided
|
||||
- **THEN** an OTP challenge is required
|
||||
EOF
|
||||
|
||||
# 4) Validate
|
||||
openspec validate $CHANGE --strict
|
||||
```
|
||||
|
||||
## Multi-Capability Example
|
||||
|
||||
```
|
||||
openspec/changes/add-2fa-notify/
|
||||
├── proposal.md
|
||||
├── tasks.md
|
||||
└── specs/
|
||||
├── auth/
|
||||
│ └── spec.md # ADDED: Two-Factor Authentication
|
||||
└── notifications/
|
||||
└── spec.md # ADDED: OTP email notification
|
||||
```
|
||||
|
||||
auth/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: Two-Factor Authentication
|
||||
...
|
||||
```
|
||||
|
||||
notifications/spec.md
|
||||
```markdown
|
||||
## ADDED Requirements
|
||||
### Requirement: OTP Email Notification
|
||||
...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Simplicity First
|
||||
- Default to <100 lines of new code
|
||||
- Single-file implementations until proven insufficient
|
||||
- Avoid frameworks without clear justification
|
||||
- Choose boring, proven patterns
|
||||
|
||||
### Complexity Triggers
|
||||
Only add complexity with:
|
||||
- Performance data showing current solution too slow
|
||||
- Concrete scale requirements (>1000 users, >100MB data)
|
||||
- Multiple proven use cases requiring abstraction
|
||||
|
||||
### Clear References
|
||||
- Use `file.ts:42` format for code locations
|
||||
- Reference specs as `specs/auth/spec.md`
|
||||
- Link related changes and PRs
|
||||
|
||||
### Capability Naming
|
||||
- Use verb-noun: `user-auth`, `payment-capture`
|
||||
- Single purpose per capability
|
||||
- 10-minute understandability rule
|
||||
- Split if description needs "AND"
|
||||
|
||||
### Change ID Naming
|
||||
- Use kebab-case, short and descriptive: `add-two-factor-auth`
|
||||
- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-`
|
||||
- Ensure uniqueness; if taken, append `-2`, `-3`, etc.
|
||||
|
||||
## Tool Selection Guide
|
||||
|
||||
| Task | Tool | Why |
|
||||
|------|------|-----|
|
||||
| Find files by pattern | Glob | Fast pattern matching |
|
||||
| Search code content | Grep | Optimized regex search |
|
||||
| Read specific files | Read | Direct file access |
|
||||
| Explore unknown scope | Task | Multi-step investigation |
|
||||
|
||||
## Error Recovery
|
||||
|
||||
### Change Conflicts
|
||||
1. Run `openspec list` to see active changes
|
||||
2. Check for overlapping specs
|
||||
3. Coordinate with change owners
|
||||
4. Consider combining proposals
|
||||
|
||||
### Validation Failures
|
||||
1. Run with `--strict` flag
|
||||
2. Check JSON output for details
|
||||
3. Verify spec file format
|
||||
4. Ensure scenarios properly formatted
|
||||
|
||||
### Missing Context
|
||||
1. Read project.md first
|
||||
2. Check related specs
|
||||
3. Review recent archives
|
||||
4. Ask for clarification
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Stage Indicators
|
||||
- `changes/` - Proposed, not yet built
|
||||
- `specs/` - Built and deployed
|
||||
- `archive/` - Completed changes
|
||||
|
||||
### File Purposes
|
||||
- `proposal.md` - Why and what
|
||||
- `tasks.md` - Implementation steps
|
||||
- `design.md` - Technical decisions
|
||||
- `spec.md` - Requirements and behavior
|
||||
|
||||
### CLI Essentials
|
||||
```bash
|
||||
openspec list # What's in progress?
|
||||
openspec show [item] # View details
|
||||
openspec validate --strict # Is it correct?
|
||||
openspec archive <change-id> [--yes|-y] # Mark complete (add --yes for automation)
|
||||
```
|
||||
|
||||
Remember: Specs are truth. Changes are proposals. Keep them in sync.
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
# Change: Add single-spacing transformer for markdown
|
||||
|
||||
## Why
|
||||
|
||||
Users may want to collapse multiple consecutive blank lines in pasted content into single blank lines without removing all blank lines entirely. This provides a middle-ground option between preserving all blank lines as-is and removing them completely.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Add a new markdown transformer that collapses multiple consecutive blank lines (2+) into a single blank line
|
||||
- Add a new setting "Convert to single-spaced" that appears above "Remove empty lines" in the Markdown transformations section
|
||||
- The setting defaults to Off (false) to preserve existing behavior
|
||||
- When "Remove empty lines" is enabled, the "Convert to single-spaced" option is disabled (not applicable) since all blank lines are removed anyway
|
||||
- The transformer is applied in the markdown transformation pipeline before empty line removal
|
||||
|
||||
## Impact
|
||||
|
||||
- Affected specs: `markdown-transformations`
|
||||
- Affected code:
|
||||
- `src/markdownTransformer.ts` - Add single-spacing logic
|
||||
- `src/main.ts` - Add `convertToSingleSpaced` setting and UI toggle with conditional disabling
|
||||
- No breaking changes
|
||||
- Backward compatible: defaults to Off, existing behavior unchanged
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# Markdown Transformations
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Single-spacing transformation
|
||||
The system SHALL provide an option to collapse multiple consecutive blank lines into a single blank line in the markdown output.
|
||||
|
||||
#### Scenario: Multiple blank lines collapsed to single
|
||||
- **WHEN** `convertToSingleSpaced` is enabled (true)
|
||||
- **AND** the markdown content contains 2 or more consecutive blank lines
|
||||
- **THEN** the consecutive blank lines SHALL be replaced with exactly 1 blank line
|
||||
- **AND** the `appliedTransformations` flag SHALL be set to true
|
||||
|
||||
#### Scenario: Single blank lines preserved
|
||||
- **WHEN** `convertToSingleSpaced` is enabled (true)
|
||||
- **AND** the markdown content contains single blank lines (not consecutive)
|
||||
- **THEN** those single blank lines SHALL be preserved as-is
|
||||
- **AND** no transformation is applied to those lines
|
||||
|
||||
#### Scenario: Non-blank lines unaffected
|
||||
- **WHEN** `convertToSingleSpaced` is enabled (true)
|
||||
- **THEN** all non-blank lines SHALL remain unchanged
|
||||
|
||||
#### Scenario: Feature disabled preserves original behavior
|
||||
- **WHEN** `convertToSingleSpaced` is disabled (false)
|
||||
- **THEN** all blank lines SHALL remain unchanged regardless of how many are consecutive
|
||||
- **AND** the `appliedTransformations` flag SHALL NOT be set based on spacing
|
||||
|
||||
#### Scenario: Interaction with removeEmptyLines
|
||||
- **WHEN** `removeEmptyLines` is enabled (true)
|
||||
- **THEN** the single-spacing transformer SHALL be skipped (not applied)
|
||||
- **AND** the empty line removal logic SHALL be applied instead
|
||||
|
||||
### Requirement: Single-spacing configuration
|
||||
The system SHALL provide a setting to enable or disable the single-spacing transformation.
|
||||
|
||||
#### Scenario: Setting defaults to disabled
|
||||
- **WHEN** the plugin is initialized with no prior settings
|
||||
- **THEN** the `convertToSingleSpaced` setting SHALL default to false
|
||||
|
||||
#### Scenario: Setting persists across sessions
|
||||
- **WHEN** a user enables or disables the `convertToSingleSpaced` setting
|
||||
- **AND** the settings are saved
|
||||
- **THEN** the setting value SHALL be persisted and restored on plugin reload
|
||||
|
||||
### Requirement: Single-spacing UI control
|
||||
The system SHALL provide a user interface control for the single-spacing transformation setting.
|
||||
|
||||
#### Scenario: Toggle positioned above "Remove empty lines"
|
||||
- **WHEN** the settings UI is displayed
|
||||
- **THEN** the "Convert to single-spaced" toggle SHALL appear in the Markdown transformations section
|
||||
- **AND** it SHALL be positioned above the "Remove empty lines" toggle
|
||||
|
||||
#### Scenario: Toggle description
|
||||
- **WHEN** the "Convert to single-spaced" toggle is displayed
|
||||
- **THEN** the description SHALL read "Collapse multiple consecutive blank lines into a single blank line"
|
||||
|
||||
#### Scenario: Toggle disabled when removeEmptyLines is enabled
|
||||
- **WHEN** the "Remove empty lines" setting is enabled (true)
|
||||
- **THEN** the "Convert to single-spaced" toggle SHALL be disabled (non-interactive)
|
||||
- **AND** it SHALL be visually indicated as disabled (grayed out or similar)
|
||||
|
||||
#### Scenario: Toggle enabled when removeEmptyLines is disabled
|
||||
- **WHEN** the "Remove empty lines" setting is disabled (false)
|
||||
- **THEN** the "Convert to single-spaced" toggle SHALL be enabled (interactive)
|
||||
- **AND** users SHALL be able to toggle it on or off
|
||||
|
||||
#### Scenario: UI updates when removeEmptyLines changes
|
||||
- **WHEN** the "Remove empty lines" setting is toggled
|
||||
- **THEN** the disabled/enabled state of the "Convert to single-spaced" toggle SHALL update accordingly
|
||||
|
||||
### Requirement: Transform pipeline order
|
||||
The system SHALL apply transformations in a defined order to ensure consistent behavior.
|
||||
|
||||
#### Scenario: Single-spacing before empty line removal
|
||||
- **WHEN** both `convertToSingleSpaced` and `removeEmptyLines` are configured (regardless of values)
|
||||
- **THEN** the single-spacing transformation logic SHALL be evaluated before the empty line removal logic in the code
|
||||
- **AND** if `removeEmptyLines` is true, single-spacing SHALL be skipped as an optimization
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
# Implementation Tasks
|
||||
|
||||
## 1. Update settings interface and defaults
|
||||
- [x] 1.1 Add `convertToSingleSpaced: boolean` property to `PasteReformmatterSettings` interface in `src/main.ts`
|
||||
- [x] 1.2 Add `convertToSingleSpaced: false` to `DEFAULT_SETTINGS` in `src/main.ts`
|
||||
|
||||
## 2. Implement single-spacing transformer
|
||||
- [x] 2.1 Add `convertToSingleSpaced` parameter to `transformMarkdown` function signature in `src/markdownTransformer.ts`
|
||||
- [x] 2.2 Implement single-spacing logic that collapses 2+ consecutive blank lines into 1 blank line
|
||||
- [x] 2.3 Apply transformer before the `removeEmptyLines` logic in the transformation pipeline
|
||||
- [x] 2.4 Skip single-spacing transformer if `removeEmptyLines` is enabled (optimization - no point processing if lines will be removed)
|
||||
- [x] 2.5 Set `appliedTransformations` flag when single-spacing makes changes
|
||||
|
||||
## 3. Add settings UI
|
||||
- [x] 3.1 Add "Convert to single-spaced" toggle in settings UI, positioned above "Remove empty lines" setting
|
||||
- [x] 3.2 Set description text: "Collapse multiple consecutive blank lines into a single blank line"
|
||||
- [x] 3.3 Implement conditional disabling: when `removeEmptyLines` is true, disable the toggle and show it as disabled/grayed
|
||||
- [x] 3.4 Update onChange handler to save settings
|
||||
- [x] 3.5 When `removeEmptyLines` changes, refresh display to update disabled state of "Convert to single-spaced" toggle
|
||||
|
||||
## 4. Integration
|
||||
- [x] 4.1 Pass `convertToSingleSpaced` setting to `transformMarkdown` call in `src/main.ts:doPaste()`
|
||||
- [x] 4.2 Verify backward compatibility with existing settings (defaults to false)
|
||||
|
||||
## 5. Testing
|
||||
- [x] 5.1 Build project: `npm run build`
|
||||
- [x] 5.2 Test single-spacing with text containing 2+ consecutive blank lines
|
||||
- [x] 5.3 Test that single-spacing is skipped when "Remove empty lines" is enabled
|
||||
- [x] 5.4 Test UI toggle behavior and disabled state interaction
|
||||
- [x] 5.5 Test that existing behavior is preserved when feature is disabled (default)
|
||||
193
openspec/project.md
Normal file
193
openspec/project.md
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
# Project Context
|
||||
|
||||
## Purpose
|
||||
|
||||
Paste Reformatter is an Obsidian plugin that gives users precise control over how pasted HTML and plain text content is transformed when pasted into notes. The plugin automatically processes clipboard content to:
|
||||
|
||||
- Transform HTML content before converting to Markdown for better formatting results
|
||||
- Apply customizable regex transformations to both HTML and Markdown
|
||||
- Automatically adjust heading levels to match document context
|
||||
- Clean up unwanted formatting (empty elements, hard line breaks, blank lines)
|
||||
- Escape markdown syntax when needed
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **TypeScript 4.7.4** - Primary language (ES6 target)
|
||||
- **Obsidian API** - Plugin framework and editor integration
|
||||
- **esbuild 0.17.3** - Build tool and bundler
|
||||
- **DOM APIs** - HTML parsing and manipulation
|
||||
- **Obsidian's htmlToMarkdown** - Built-in HTML to Markdown conversion
|
||||
|
||||
## Project Conventions
|
||||
|
||||
### Code Style
|
||||
|
||||
- TypeScript with strict null checks enabled
|
||||
- ES6+ syntax with ESNext module format
|
||||
- 4-space tabs for indentation
|
||||
- Camel case for variables and functions, PascalCase for classes/interfaces
|
||||
- Comprehensive JSDoc comments for public functions
|
||||
- Descriptive variable names (e.g., `appliedTransformations`, `contextLevel`)
|
||||
|
||||
### Architecture Patterns
|
||||
|
||||
The codebase follows a modular architecture with clear separation of concerns:
|
||||
|
||||
**Core Plugin Structure (`src/main.ts`)**:
|
||||
- `PasteReformatter` class extends Obsidian's `Plugin` class
|
||||
- Registers clipboard event handlers and commands
|
||||
- Manages settings persistence via `loadData()`/`saveData()`
|
||||
- Delegates transformation logic to specialized modules
|
||||
|
||||
**HTML Transformation Module (`src/htmlTransformer.ts`)**:
|
||||
- Pure function: `transformHTML(html, settings) => {html, appliedTransformations}`
|
||||
- Uses DOMParser for safe HTML manipulation (never uses innerHTML)
|
||||
- Applies transformations in sequence: regex → line breaks → empty elements
|
||||
- Returns transformation status to determine if paste should be intercepted
|
||||
|
||||
**Markdown Transformation Module (`src/markdownTransformer.ts`)**:
|
||||
- Pure function: `transformMarkdown(markdown, settings, contextLevel, escapeMarkdown) => {markdown, appliedTransformations}`
|
||||
- Handles heading level adjustments with three modes:
|
||||
- Contextual cascade (based on cursor position)
|
||||
- Standard cascade (based on max heading level)
|
||||
- Simple capping (no cascade)
|
||||
- Applies regex replacements and empty line removal
|
||||
- Supports markdown escaping for special paste mode
|
||||
|
||||
**Settings Management**:
|
||||
- `PasteReformmatterSettings` interface defines all configuration
|
||||
- `DEFAULT_SETTINGS` constant for initialization
|
||||
- Custom settings tab with rich UI (tables, icons, accessibility features)
|
||||
- Settings UI built entirely with DOM APIs (no innerHTML)
|
||||
|
||||
**Key Design Patterns**:
|
||||
- **Transformation Pipeline**: HTML → Markdown → Final transformations
|
||||
- **Non-invasive Override**: Only prevents default paste if transformations were applied
|
||||
- **Safe DOM Manipulation**: Exclusively uses createElement/appendChild patterns
|
||||
- **Accessibility First**: All UI elements include ARIA labels and keyboard support
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
Currently, the project does not have automated tests. Testing is performed manually:
|
||||
|
||||
- Use `npm run build` to verify TypeScript compilation
|
||||
- Manual testing in Obsidian vault with various paste scenarios
|
||||
- Test different content sources (web pages, Google Docs, Word, plain text)
|
||||
- Verify heading cascade behavior with cascade-rules.md examples
|
||||
- Check settings UI behavior and persistence
|
||||
|
||||
When adding tests, prioritize:
|
||||
1. Transformation logic (htmlTransformer, markdownTransformer)
|
||||
2. Heading cascade algorithms (all three modes)
|
||||
3. Regex replacement handling and error cases
|
||||
4. Settings persistence and migration
|
||||
|
||||
### Git Workflow
|
||||
|
||||
- **Main branch**: Production-ready code
|
||||
- **Commit style**: Conventional commits format
|
||||
- `feat:` - New features
|
||||
- `fix:` - Bug fixes
|
||||
- `refactor:` - Code improvements without behavior change
|
||||
- `chore:` - Maintenance tasks (version bumps, dependencies)
|
||||
- `docs:` - Documentation updates
|
||||
- **Version bumping**: Automated via `version-bump.mjs` script
|
||||
- **Release process**: GitHub Actions workflow (`.github/workflows/release.yml`)
|
||||
|
||||
## Domain Context
|
||||
|
||||
### Obsidian Plugin Ecosystem
|
||||
|
||||
- Plugins extend Obsidian's markdown editor functionality
|
||||
- Must work across desktop and mobile (this plugin works on both)
|
||||
- Need to respect user themes and CSS variables
|
||||
- Should integrate cleanly with other plugins (non-invasive event handling)
|
||||
|
||||
### Clipboard Data Handling
|
||||
|
||||
The plugin processes clipboard data in this order of priority:
|
||||
1. **HTML format** (`text/html`) - Preferred for rich content from web/apps
|
||||
2. **Plain text** (`text/plain`) - Treated as already being Markdown
|
||||
|
||||
### Heading Cascade Logic
|
||||
|
||||
Three distinct modes with different behaviors (see `cascade-rules.md`):
|
||||
|
||||
1. **No Cascade** (maxHeadingLevel=1 OR cascadeHeadingLevels=false):
|
||||
- Simple capping: H1→H3, H2→H3 if max is H3
|
||||
|
||||
2. **Standard Cascade** (cascadeHeadingLevels=true, contextualCascade=false):
|
||||
- Preserves hierarchy: H1→H3, H2→H4, H3→H5 if max is H3
|
||||
- Caps at H6
|
||||
|
||||
3. **Contextual Cascade** (contextualCascade=true):
|
||||
- Overrides other settings
|
||||
- Adjusts based on cursor's current heading section
|
||||
- In H2 section: H1→H3, H2→H4, H3→H5, etc.
|
||||
|
||||
### Special Markers
|
||||
|
||||
- `data-preserve="true"` attribute marks line breaks to preserve during empty element removal
|
||||
- Zero-width space (`\u200B`) used as placeholder to prevent paragraph collapse
|
||||
|
||||
## Important Constraints
|
||||
|
||||
### Security
|
||||
|
||||
- **Never use innerHTML or outerHTML** - XSS vulnerability risk
|
||||
- Always use DOMParser, createElement, appendChild patterns
|
||||
- Sanitize user-provided regex patterns with try-catch blocks
|
||||
|
||||
### Obsidian API Compatibility
|
||||
|
||||
- Minimum supported version: 0.15.0 (defined in manifest.json)
|
||||
- Must use current (non-deprecated) API methods
|
||||
- Use `app.workspace.getActiveViewOfType(MarkdownView)` not deprecated alternatives
|
||||
- Register events with `registerEvent()` for automatic cleanup
|
||||
|
||||
### Performance
|
||||
|
||||
- Transformations must be fast (paste should feel instant)
|
||||
- Regex replacements loop through array - keep replacement lists reasonable
|
||||
- DOM parsing happens on every paste when enabled - keep HTML clean
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
- Must work in Electron (Chromium-based)
|
||||
- Uses modern APIs: DOMParser, XMLSerializer, navigator.clipboard
|
||||
- Targets ES2018 in build output
|
||||
|
||||
## External Dependencies
|
||||
|
||||
### Runtime Dependencies
|
||||
|
||||
- **Obsidian API** (`obsidian` module) - Provides:
|
||||
- `Plugin` base class
|
||||
- `MarkdownView` for editor access
|
||||
- `Setting` for settings UI
|
||||
- `htmlToMarkdown()` conversion function
|
||||
- `Notice` for user notifications
|
||||
- `setIcon()` for UI icons
|
||||
|
||||
### Development Dependencies
|
||||
|
||||
- **esbuild** - Fast TypeScript bundler
|
||||
- **TypeScript** - Type checking and compilation
|
||||
- **@types/node** - Node.js type definitions
|
||||
- **tslib** - TypeScript runtime helpers
|
||||
- **builtin-modules** - List of Node.js built-in modules to exclude from bundle
|
||||
|
||||
### Build Process
|
||||
|
||||
1. TypeScript compilation check (`tsc -noEmit -skipLibCheck`)
|
||||
2. esbuild bundles `src/main.ts` → `dist/main.js`
|
||||
3. Styles copied from `styles.css` to dist
|
||||
4. External dependencies (obsidian, electron, codemirror) marked as external
|
||||
5. Production builds are minified, dev builds include inline sourcemaps
|
||||
|
||||
### Distribution
|
||||
|
||||
- Main files: `dist/main.js`, `manifest.json`, `styles.css`
|
||||
- Distributed via Obsidian Community Plugins marketplace
|
||||
- GitHub releases include ZIP with required files
|
||||
- Version managed in: `manifest.json`, `package.json`, `versions.json`
|
||||
80
openspec/specs/markdown-transformations/spec.md
Normal file
80
openspec/specs/markdown-transformations/spec.md
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# markdown-transformations Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change add-single-spacing-transformer. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Single-spacing transformation
|
||||
The system SHALL provide an option to collapse multiple consecutive blank lines into a single blank line in the markdown output.
|
||||
|
||||
#### Scenario: Multiple blank lines collapsed to single
|
||||
- **WHEN** `convertToSingleSpaced` is enabled (true)
|
||||
- **AND** the markdown content contains 2 or more consecutive blank lines
|
||||
- **THEN** the consecutive blank lines SHALL be replaced with exactly 1 blank line
|
||||
- **AND** the `appliedTransformations` flag SHALL be set to true
|
||||
|
||||
#### Scenario: Single blank lines preserved
|
||||
- **WHEN** `convertToSingleSpaced` is enabled (true)
|
||||
- **AND** the markdown content contains single blank lines (not consecutive)
|
||||
- **THEN** those single blank lines SHALL be preserved as-is
|
||||
- **AND** no transformation is applied to those lines
|
||||
|
||||
#### Scenario: Non-blank lines unaffected
|
||||
- **WHEN** `convertToSingleSpaced` is enabled (true)
|
||||
- **THEN** all non-blank lines SHALL remain unchanged
|
||||
|
||||
#### Scenario: Feature disabled preserves original behavior
|
||||
- **WHEN** `convertToSingleSpaced` is disabled (false)
|
||||
- **THEN** all blank lines SHALL remain unchanged regardless of how many are consecutive
|
||||
- **AND** the `appliedTransformations` flag SHALL NOT be set based on spacing
|
||||
|
||||
#### Scenario: Interaction with removeEmptyLines
|
||||
- **WHEN** `removeEmptyLines` is enabled (true)
|
||||
- **THEN** the single-spacing transformer SHALL be skipped (not applied)
|
||||
- **AND** the empty line removal logic SHALL be applied instead
|
||||
|
||||
### Requirement: Single-spacing configuration
|
||||
The system SHALL provide a setting to enable or disable the single-spacing transformation.
|
||||
|
||||
#### Scenario: Setting defaults to disabled
|
||||
- **WHEN** the plugin is initialized with no prior settings
|
||||
- **THEN** the `convertToSingleSpaced` setting SHALL default to false
|
||||
|
||||
#### Scenario: Setting persists across sessions
|
||||
- **WHEN** a user enables or disables the `convertToSingleSpaced` setting
|
||||
- **AND** the settings are saved
|
||||
- **THEN** the setting value SHALL be persisted and restored on plugin reload
|
||||
|
||||
### Requirement: Single-spacing UI control
|
||||
The system SHALL provide a user interface control for the single-spacing transformation setting.
|
||||
|
||||
#### Scenario: Toggle positioned above "Remove empty lines"
|
||||
- **WHEN** the settings UI is displayed
|
||||
- **THEN** the "Convert to single-spaced" toggle SHALL appear in the Markdown transformations section
|
||||
- **AND** it SHALL be positioned above the "Remove empty lines" toggle
|
||||
|
||||
#### Scenario: Toggle description
|
||||
- **WHEN** the "Convert to single-spaced" toggle is displayed
|
||||
- **THEN** the description SHALL read "Collapse multiple consecutive blank lines into a single blank line"
|
||||
|
||||
#### Scenario: Toggle disabled when removeEmptyLines is enabled
|
||||
- **WHEN** the "Remove empty lines" setting is enabled (true)
|
||||
- **THEN** the "Convert to single-spaced" toggle SHALL be disabled (non-interactive)
|
||||
- **AND** it SHALL be visually indicated as disabled (grayed out or similar)
|
||||
|
||||
#### Scenario: Toggle enabled when removeEmptyLines is disabled
|
||||
- **WHEN** the "Remove empty lines" setting is disabled (false)
|
||||
- **THEN** the "Convert to single-spaced" toggle SHALL be enabled (interactive)
|
||||
- **AND** users SHALL be able to toggle it on or off
|
||||
|
||||
#### Scenario: UI updates when removeEmptyLines changes
|
||||
- **WHEN** the "Remove empty lines" setting is toggled
|
||||
- **THEN** the disabled/enabled state of the "Convert to single-spaced" toggle SHALL update accordingly
|
||||
|
||||
### Requirement: Transform pipeline order
|
||||
The system SHALL apply transformations in a defined order to ensure consistent behavior.
|
||||
|
||||
#### Scenario: Single-spacing before empty line removal
|
||||
- **WHEN** both `convertToSingleSpaced` and `removeEmptyLines` are configured (regardless of values)
|
||||
- **THEN** the single-spacing transformation logic SHALL be evaluated before the empty line removal logic in the code
|
||||
- **AND** if `removeEmptyLines` is true, single-spacing SHALL be skipped as an optimization
|
||||
|
||||
2330
package-lock.json
generated
2330
package-lock.json
generated
File diff suppressed because it is too large
Load diff
44
package.json
44
package.json
|
|
@ -1,24 +1,24 @@
|
|||
{
|
||||
"name": "obsidian-paste-reformatter",
|
||||
"version": "1.4.2",
|
||||
"description": "Reformat pasted text for precise control.",
|
||||
"main": "main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
"name": "obsidian-sample-plugin",
|
||||
"version": "1.3.0",
|
||||
"description": "This is a sample plugin for Obsidian (https://obsidian.md)",
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs && node copy-styles.mjs",
|
||||
"build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production && node copy-styles.mjs",
|
||||
"version": "node version-bump.mjs && git add manifest.json versions.json"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16.11.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.29.0",
|
||||
"@typescript-eslint/parser": "5.29.0",
|
||||
"builtin-modules": "3.3.0",
|
||||
"esbuild": "0.17.3",
|
||||
"obsidian": "latest",
|
||||
"tslib": "2.4.0",
|
||||
"typescript": "4.7.4"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
export function transformHTML(
|
||||
html: string,
|
||||
settings: {
|
||||
htmlRegexReplacements: Array<{ pattern: string, replacement: string }>,
|
||||
htmlRegexReplacements: Array<{pattern: string, replacement: string}>,
|
||||
stripLineBreaks: boolean,
|
||||
removeEmptyElements: boolean
|
||||
}
|
||||
|
|
@ -32,11 +32,11 @@ export function transformHTML(
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Create a temporary DOM element to parse the HTML
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
|
||||
|
||||
// Process line breaks if strip line breaks is enabled
|
||||
if (settings.stripLineBreaks) {
|
||||
// Find all <br> elements and remove them
|
||||
|
|
@ -45,8 +45,26 @@ export function transformHTML(
|
|||
br.remove();
|
||||
});
|
||||
appliedTransformations = true;
|
||||
} else {
|
||||
// If we're not stripping line breaks, convert them to special paragraph tags
|
||||
// that will be preserved even if empty elements are removed
|
||||
const brElements = doc.querySelectorAll('br');
|
||||
brElements.forEach(br => {
|
||||
// Create a special paragraph with a class that marks it as a line break
|
||||
const lineBreakP = doc.createElement('p');
|
||||
lineBreakP.className = 'preserve-line-break';
|
||||
lineBreakP.setAttribute('data-preserve', 'true');
|
||||
|
||||
// Add a non-breaking space as placeholder content
|
||||
// This ensures the paragraph isn't considered empty when Remove Empty Lines is enabled
|
||||
// The unicode character will be invisible but ensures the line isn't empty
|
||||
lineBreakP.textContent = '\u200B'; // Zero-width space
|
||||
|
||||
// Replace the <br> with our special paragraph
|
||||
br.parentNode?.replaceChild(lineBreakP, br);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Remove empty elements if enabled
|
||||
if (settings.removeEmptyElements) {
|
||||
// Function to check if an element is empty (no text content and no meaningful children)
|
||||
|
|
@ -55,35 +73,35 @@ export function transformHTML(
|
|||
if (['img', 'hr', 'br', 'input', 'iframe'].includes(element.tagName.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Skip our special line break paragraphs
|
||||
if (element.hasAttribute('data-preserve')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check if it has any text content (include whitespace)
|
||||
if (element.textContent && element.textContent.length > 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// Check if it has any non-empty children
|
||||
for (let i = 0; i < element.children.length; i++) {
|
||||
if (!isElementEmpty(element.children[i])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
// Find and remove empty elements
|
||||
// We need to use a while loop because the DOM changes as we remove elements
|
||||
let emptyElementsFound = true;
|
||||
while (emptyElementsFound) {
|
||||
emptyElementsFound = false;
|
||||
|
||||
|
||||
// Target common empty elements
|
||||
const potentialEmptyElements = doc.querySelectorAll('p, div, span, li, ul, ol, table, tr, td, th');
|
||||
const potentialEmptyElements = doc.querySelectorAll('p:not([data-preserve]), div, span, li, ul, ol, table, tr, td, th');
|
||||
potentialEmptyElements.forEach(element => {
|
||||
if (isElementEmpty(element)) {
|
||||
element.remove();
|
||||
|
|
@ -91,14 +109,14 @@ export function transformHTML(
|
|||
appliedTransformations = true;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// If no more empty elements are found, exit the loop
|
||||
if (!emptyElementsFound) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Return the modified HTML and transformation status
|
||||
const serializer = new XMLSerializer();
|
||||
return {
|
||||
|
|
|
|||
1402
src/main.ts
1402
src/main.ts
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,7 @@
|
|||
export function transformMarkdown(
|
||||
markdown: string,
|
||||
settings: {
|
||||
markdownRegexReplacements: Array<{ pattern: string, replacement: string }>,
|
||||
markdownRegexReplacements: Array<{pattern: string, replacement: string}>,
|
||||
contextualCascade: boolean,
|
||||
maxHeadingLevel: number,
|
||||
cascadeHeadingLevels: boolean,
|
||||
|
|
@ -24,38 +24,36 @@ export function transformMarkdown(
|
|||
): { markdown: string, appliedTransformations: boolean } {
|
||||
let appliedTransformations = false;
|
||||
|
||||
console.log(`original: ${markdown}`);
|
||||
|
||||
// Apply regex replacements if defined
|
||||
if (settings.markdownRegexReplacements && settings.markdownRegexReplacements.length > 0) {
|
||||
for (const regex_replacement of settings.markdownRegexReplacements) {
|
||||
try {
|
||||
const regex = new RegExp(regex_replacement.pattern, 'g');
|
||||
const replacement = regex_replacement.replacement
|
||||
.replace(/\\r\\n/g, '\r\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
.replace(/\\r\\n/g, '\r\n')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\'/g, "'")
|
||||
.replace(/\\"/g, '"')
|
||||
.replace(/\\\\/g, '\\');
|
||||
const originalMarkdown = markdown;
|
||||
// console.log(`applying ${JSON.stringify(regex_replacement.pattern)} replacement ${JSON.stringify(replacement)}`); console.log(JSON.stringify(markdown));
|
||||
markdown = markdown.replace(regex, replacement);
|
||||
if (originalMarkdown !== markdown) {
|
||||
appliedTransformations = true;
|
||||
console.log(`regex replacements: ${markdown}`);
|
||||
// console.log("replaced text")
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error applying markdown regex replacement: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (!escapeMarkdown) {
|
||||
// Find all heading lines
|
||||
const headingRegex = /^(#{1,6})\s/gm;
|
||||
|
||||
|
||||
// Process headings based on settings
|
||||
if (settings.contextualCascade && contextLevel > 0) {
|
||||
let delta = -1;
|
||||
|
|
@ -75,12 +73,12 @@ export function transformMarkdown(
|
|||
newLevel = Math.min(contextLevel + 1, 6);
|
||||
delta = newLevel - currentLevel;
|
||||
cascading = true;
|
||||
console.log(`*** contextual cascade initiated: delta: ${delta}`);
|
||||
console.log(`contextual cascade initiated: delta: ${delta}`);
|
||||
} // else nothing to do
|
||||
|
||||
console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`);
|
||||
|
||||
appliedTransformations = appliedTransformations || (newLevel !== currentLevel);
|
||||
|
||||
appliedTransformations = (newLevel !== currentLevel);
|
||||
// Return the new heading with the adjusted level
|
||||
return '#'.repeat(newLevel) + ' ';
|
||||
});
|
||||
|
|
@ -91,7 +89,7 @@ export function transformMarkdown(
|
|||
markdown = markdown.replace(headingRegex, (match, hashes) => {
|
||||
const currentLevel = hashes.length;
|
||||
let newLevel = currentLevel;
|
||||
|
||||
|
||||
if (settings.cascadeHeadingLevels) {
|
||||
// If cascading is enabled, start cascading subsequent headings down if needed
|
||||
if (cascading) {
|
||||
|
|
@ -108,10 +106,10 @@ export function transformMarkdown(
|
|||
// Cascading not enabled, just cap heading levels at max
|
||||
newLevel = Math.max(currentLevel, settings.maxHeadingLevel)
|
||||
}
|
||||
|
||||
|
||||
console.log(`result: current level: ${currentLevel}, new level: ${newLevel}`);
|
||||
|
||||
appliedTransformations = appliedTransformations || (newLevel !== currentLevel);
|
||||
appliedTransformations = (newLevel !== currentLevel);
|
||||
|
||||
// Return the new heading with the adjusted level
|
||||
return '#'.repeat(newLevel) + ' ';
|
||||
|
|
@ -121,7 +119,7 @@ export function transformMarkdown(
|
|||
// If escaping markdown, we don't want to change headings
|
||||
// Just escape the markdown content
|
||||
const originalMarkdown = markdown;
|
||||
|
||||
|
||||
// Escape all Markdown syntax that Obsidian recognizes
|
||||
// Headings, bold/italic, lists, links, images, code blocks, blockquotes, etc.
|
||||
markdown = markdown
|
||||
|
|
@ -152,83 +150,80 @@ export function transformMarkdown(
|
|||
// Otherwise it's an HTML tag that needs escaping
|
||||
return htmlTag ? '`' + htmlTag + '`' : match;
|
||||
});
|
||||
|
||||
|
||||
appliedTransformations = (originalMarkdown !== markdown);
|
||||
}
|
||||
|
||||
console.log(`processed: ${markdown}`);
|
||||
|
||||
// First handle the special line break markers
|
||||
let preserveLineBreaks = !settings.stripLineBreaks;
|
||||
|
||||
|
||||
// Convert multiple consecutive blank lines to single blank line if enabled
|
||||
// Skip this if removeEmptyLines is enabled (optimization - lines will be removed anyway)
|
||||
if (settings.convertToSingleSpaced && !settings.removeEmptyLines) {
|
||||
// Normalize line endings to ensure consistent processing
|
||||
markdown = markdown.replace(/\r\n/g, '\n');
|
||||
|
||||
|
||||
// Replace 2 or more consecutive newlines with exactly 2 newlines (1 blank line)
|
||||
const originalMarkdown = markdown;
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
|
||||
if (originalMarkdown !== markdown) {
|
||||
appliedTransformations = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Process empty lines with a sliding window approach
|
||||
if (settings.removeEmptyLines) {
|
||||
// First, normalize line endings to ensure consistent processing
|
||||
markdown = markdown.replace(/\r\n/g, '\n');
|
||||
|
||||
|
||||
// Split the content into lines
|
||||
const lines = markdown.split('\n');
|
||||
const filteredLines: string[] = [];
|
||||
|
||||
|
||||
// Sliding window processing with peek capability
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const currentLine = lines[i];
|
||||
const nextLine = i + 1 < lines.length ? lines[i + 1] : null;
|
||||
const isCurrentLineEmpty = currentLine.trim() === '';
|
||||
|
||||
|
||||
// Rule 1: Preserve line breaks - check for special markers
|
||||
if (preserveLineBreaks) {
|
||||
const hasPreserveMarker =
|
||||
const hasPreserveMarker =
|
||||
/<p class="preserve-line-break"[^>]*>.*?<\/p>/.test(currentLine) ||
|
||||
/<p data-preserve="true"[^>]*>.*?<\/p>/.test(currentLine);
|
||||
|
||||
|
||||
if (hasPreserveMarker) {
|
||||
// Insert an empty line instead of the marker
|
||||
filteredLines.push('');
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Rule 2: Keep empty line if next line is a horizontal rule (3+ dashes)
|
||||
if (isCurrentLineEmpty && nextLine !== null && /^\s*-{3,}\s*$/.test(nextLine)) {
|
||||
filteredLines.push(currentLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Rule 3: Keep empty line if next line is the beginning of a table
|
||||
if (isCurrentLineEmpty && nextLine !== null && /^\s*\|.*\|/.test(nextLine)) {
|
||||
filteredLines.push(currentLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Default: Remove empty lines unless they meet the above criteria
|
||||
if (!isCurrentLineEmpty) {
|
||||
filteredLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Join the filtered lines back together
|
||||
const originalMarkdown = markdown;
|
||||
markdown = filteredLines.join('\n');
|
||||
appliedTransformations = appliedTransformations || (originalMarkdown !== markdown);
|
||||
}
|
||||
|
||||
console.log(`final: ${markdown}`);
|
||||
|
||||
return {
|
||||
markdown,
|
||||
appliedTransformations
|
||||
|
|
|
|||
|
|
@ -5,7 +5,5 @@
|
|||
"1.2.0": "0.15.0",
|
||||
"1.2.1": "0.15.0",
|
||||
"1.2.2": "0.15.0",
|
||||
"1.3.0": "0.15.0",
|
||||
"1.4.0": "0.15.0",
|
||||
"1.4.2": "0.15.0"
|
||||
"1.3.0": "0.15.0"
|
||||
}
|
||||
Loading…
Reference in a new issue