Storyteller Suite - Build and Testing

Prerequisites

# Check versions
node --version  # Should be 18.18.0 - 22.x
npm --version

Installation

# Clone repository
git clone https://github.com/maws7140/obsidian-storyteller-suite.git
cd obsidian-storyteller-suite

# Install dependencies
npm install

npm Scripts

Script Command Purpose
dev npm run dev Development build with watch mode
build npm run build Production build
test npm run test Run tests once
test:watch npm run test:watch Run tests in watch mode
version npm version X.Y.Z Bump version

Development Build

npm run dev

This runs esbuild.config.mjs which:

  1. Watches for file changes
  2. Bundles TypeScript to main.js
  3. Copies CSS to styles.css
  4. Outputs to project root

esbuild.config.mjs

import esbuild from 'esbuild';
import { builtinModules } from 'module';

const production = process.argv[2] === 'production';

const context = await esbuild.context({
    entryPoints: ['src/main.ts'],
    bundle: true,
    format: 'cjs',
    target: 'es2018',
    logLevel: 'info',
    sourcemap: production ? false : 'inline',
    treeShaking: true,
    outfile: 'main.js',
    external: [
        'obsidian',
        'electron',
        '@codemirror/*',
        ...builtinModules.map(m => `node:${m}`),
    ],
    define: {
        'process.env.NODE_ENV': production ? '"production"' : '"development"'
    },
});

if (production) {
    await context.rebuild();
    process.exit(0);
} else {
    await context.watch();
}

Production Build

npm run build

This runs:

  1. TypeScript type checking (tsc -p tsconfig.json --noEmit)
  2. esbuild production bundle (node esbuild.config.mjs production)

Output Files

File Description
main.js Bundled plugin code
styles.css Plugin styles
manifest.json Plugin manifest (already exists)

TypeScript Configuration

tsconfig.json

{
    "compilerOptions": {
        "baseUrl": ".",
        "inlineSourceMap": true,
        "inlineSources": true,
        "module": "ESNext",
        "target": "ES6",
        "allowJs": true,
        "noImplicitAny": true,
        "moduleResolution": "node",
        "importHelpers": true,
        "isolatedModules": true,
        "strictNullChecks": true,
        "lib": ["DOM", "ES5", "ES6", "ES7"],
        "resolveJsonModule": true,
        "esModuleInterop": true,
        "skipLibCheck": true
    },
    "include": ["**/*.ts"]
}

Testing

Test Framework

Uses Vitest with configuration in vitest.config.ts:

import { defineConfig } from 'vitest/config';
import path from 'path';

export default defineConfig({
    test: {
        globals: true,
        environment: 'node',
    },
    resolve: {
        alias: {
            'obsidian': path.resolve(__dirname, 'test/__mocks__/obsidian.ts'),
        },
    },
});

Running Tests

# Run all tests once
npm run test

# Run tests in watch mode
npm run test:watch

# Run specific test file
npx vitest run test/utils/DateParsing.test.ts

Test Structure

test/
├── __mocks__/
│   └── obsidian.ts           # Mock Obsidian API
├── folders/
│   └── FolderResolver.test.ts
├── utils/
│   ├── DateParsing.test.ts
│   ├── EntityTemplates.test.ts
│   └── YamlSerializer.test.ts
├── yaml/
│   └── EntitySections.test.ts
└── integration/
    └── empty-fields-preservation.test.ts

Mock Obsidian API

// test/__mocks__/obsidian.ts
export class App {
    vault = new Vault();
    workspace = new Workspace();
}

export class Vault {
    adapter = {
        read: vi.fn(),
        write: vi.fn(),
        exists: vi.fn(),
    };
    
    getAbstractFileByPath = vi.fn();
    getMarkdownFiles = vi.fn(() => []);
    cachedRead = vi.fn();
    modify = vi.fn();
    create = vi.fn();
}

export class Modal {
    app: App;
    contentEl = document.createElement('div');
    
    open = vi.fn();
    close = vi.fn();
    onOpen = vi.fn();
    onClose = vi.fn();
}

// ... other mocks

Example Test

// test/utils/DateParsing.test.ts
import { describe, it, expect } from 'vitest';
import { parseDate, formatDate } from '../../src/utils/DateParsing';

describe('DateParsing', () => {
    describe('parseDate', () => {
        it('should parse ISO dates', () => {
            const result = parseDate('2023-06-15');
            expect(result).not.toBeNull();
            expect(result?.getFullYear()).toBe(2023);
            expect(result?.getMonth()).toBe(5); // 0-indexed
            expect(result?.getDate()).toBe(15);
        });
        
        it('should parse natural language dates', () => {
            const result = parseDate('next Friday');
            expect(result).not.toBeNull();
            expect(result?.getDay()).toBe(5); // Friday
        });
        
        it('should return null for invalid dates', () => {
            const result = parseDate('not a date');
            expect(result).toBeNull();
        });
    });
    
    describe('formatDate', () => {
        it('should format date to ISO string', () => {
            const date = new Date(2023, 5, 15);
            const result = formatDate(date);
            expect(result).toBe('2023-06-15');
        });
    });
});

ESLint

Configuration (.eslintrc)

{
    "root": true,
    "parser": "@typescript-eslint/parser",
    "env": { "node": true },
    "plugins": ["@typescript-eslint"],
    "extends": [
        "eslint:recommended",
        "plugin:@typescript-eslint/eslint-recommended",
        "plugin:@typescript-eslint/recommended"
    ],
    "parserOptions": {
        "sourceType": "module"
    },
    "rules": {
        "no-unused-vars": "off",
        "@typescript-eslint/no-unused-vars": ["error", { "args": "none" }],
        "@typescript-eslint/ban-ts-comment": "off",
        "no-prototype-builtins": "off",
        "@typescript-eslint/no-empty-function": "off"
    }
}

Running Linter

npx eslint src/ --ext .ts

Version Bumping

Manual Version Update

npm version patch  # 1.5.3 → 1.5.4
npm version minor  # 1.5.3 → 1.6.0
npm version major  # 1.5.3 → 2.0.0

This runs version-bump.mjs which updates:

version-bump.mjs

import { readFileSync, writeFileSync } from 'fs';

const targetVersion = process.env.npm_package_version;

// Update manifest.json
let manifest = JSON.parse(readFileSync('manifest.json', 'utf8'));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync('manifest.json', JSON.stringify(manifest, null, '\t'));

// Update versions.json
let versions = JSON.parse(readFileSync('versions.json', 'utf8'));
versions[targetVersion] = minAppVersion;
writeFileSync('versions.json', JSON.stringify(versions, null, '\t'));

Release Workflow

GitHub Actions (.github/workflows/release.yml)

Triggered manually via workflow_dispatch:

name: Release

on:
  workflow_dispatch:
    inputs:
      version:
        description: 'Version to release (e.g., 1.5.2)'
        required: true
      release_notes:
        description: 'Release notes (optional)'
        required: false

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '18.x'
      
      - run: npm ci
      - run: npm run build
      
      # Update version files
      # Create release package
      # Create GitHub Release

Release Steps

  1. Update version files - package.json, manifest.json, versions.json
  2. Build - Type check and bundle
  3. Verify - Check main.js exists
  4. Package - Create zip file
  5. Tag - Create git tag
  6. Release - Create GitHub Release with assets

Local Development Setup

# Create symlink to test vault
ln -s /path/to/project /path/to/vault/.obsidian/plugins/storyteller-suite

# Or copy files
cp main.js manifest.json styles.css /path/to/vault/.obsidian/plugins/storyteller-suite/

Hot Reload

  1. Run npm run dev (watches for changes)
  2. In Obsidian: Ctrl/Cmd + R to reload
  3. Or use the Hot Reload plugin

Dependency Management

Renovate Bot

Configured via renovate.json:

{
    "$schema": "https://docs.renovatebot.com/renovate-schema.json",
    "extends": ["config:recommended"],
    "rangeStrategy": "replace",
    "labels": ["dependencies"],
    "packageRules": [
        {
            "matchManagers": ["npm"],
            "separateMinorPatch": true
        }
    ]
}

Dependabot

Configured via .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly

Debug Testing

test-parsing.js

Simple script to test markdown parsing:

// Run with: node test-parsing.js
function parseSectionsFromMarkdown(content) {
    // Section parsing logic
}

const testContent = `---
name: "Test Character"
---

## Description
A brave warrior

## Backstory
Born in a small village...
`;

console.log(parseSectionsFromMarkdown(testContent));

debug-test.js

Additional debugging utilities.

Editor Configuration

.editorconfig

root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
tab_width = 4

Troubleshooting

Common Issues

Build fails with type errors:

# Check TypeScript errors
npx tsc -p tsconfig.json --noEmit

Tests fail with module not found:

# Ensure mocks are set up
# Check vitest.config.ts alias configuration

Plugin not loading:

  1. Check manifest.json is valid JSON
  2. Verify main.js exists
  3. Check Obsidian console for errors

Styles not applying:

  1. Verify styles.css exists in plugin folder
  2. Check for CSS syntax errors