feat(seeder): auto-deploy shipped templates + README to user vault on first run

A freshly-installed perplexed has no templates folder, so the directory-template
commands have nothing to match. Users had to copy templates from the plugin
source by hand. Now the four shipped templates (concept-, vocabulary-, source-,
toolkit-profile) plus a README are inlined into main.js via esbuild's text
loader for .md files, and a seeder writes them into the configured templates
root on plugin load — but only if the folder is missing or contains no markdown,
so existing customizations are never touched.

A new "Re-seed templates" button in the Directory templates settings section
fills in any shipped file whose filename doesn't already exist (for pulling in
new templates after a plugin update).

Pieces:
- esbuild.config.mjs: register `.md` text loader so markdown imports work
- src/types/markdown.d.ts: TS shim for `*.md` imports
- src/services/templateSeederService.ts: seedTemplatesIfMissing +
  reSeedMissingFiles (idempotent, never overwrites)
- src/docs/templates/README.md: end-user docs for the template system
  (zone anatomy, interpolation tokens, commands, image markers, citation
  behavior, frontmatter stamps, writing a custom template)
- main.ts: seeder fires after settings load; settings tab gains a Re-seed
  button

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mpstaton 2026-05-09 23:34:41 -05:00
parent 9af617f38c
commit 3349e36bd6
5 changed files with 277 additions and 2 deletions

View file

@ -54,7 +54,7 @@ const context = await esbuild.context({
},
logLevel: 'info',
outfile: 'main.js',
loader: { '.css': 'text' },
loader: { '.css': 'text', '.md': 'text' },
});
if (isProduction) {

31
main.ts
View file

@ -33,6 +33,7 @@ import type { DirectoryTemplateSettings, ParsedTemplate } from './src/services/d
import type { TFile } from 'obsidian';
import { findImagesForSelection } from './src/services/findImagesService';
import type { FindImagesSettings } from './src/services/findImagesService';
import { reSeedMissingFiles, seedTemplatesIfMissing } from './src/services/templateSeederService';
interface PerplexedPluginSettings {
@ -329,7 +330,20 @@ export default class PerplexedPlugin extends Plugin {
await this.loadSettings();
console.debug('Perplexed Plugin: Settings loaded successfully');
// First-run seeding: if the configured templates root is missing
// or empty, drop in the four shipped templates plus a README so a
// freshly-installed perplexed has working defaults out of the box.
// Idempotent — never overwrites existing files.
try {
const result = await seedTemplatesIfMissing(this.app, this.settings.directoryTemplatesRoot);
if (result.seeded > 0) {
console.debug(`Perplexed Plugin: seeded ${result.seeded.toString()} template(s) (${result.reason})`);
}
} catch (error) {
console.error('Perplexed Plugin: template seeding failed:', error);
}
// Initialize prompts service first
try {
this.promptsService = new PromptsService(this.settings.prompts);
@ -1796,6 +1810,21 @@ class PerplexedSettingTab extends PluginSettingTab {
})
);
new Setting(containerEl)
.setName('Re-seed templates')
.setDesc('Write any shipped template files that are missing from the templates root. Existing files are never overwritten — to reset a template to its shipped default, delete the file first then re-seed.')
.addButton(btn => btn
.setButtonText('Re-seed')
.onClick(async () => {
try {
await reSeedMissingFiles(this.plugin.app, this.plugin.settings.directoryTemplatesRoot);
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
new Notice(`Re-seed failed: ${msg}`);
}
})
);
// Find images for selection
new Setting(containerEl).setName('Find images for selection').setHeading();
containerEl.createEl('p', {

131
src/docs/templates/README.md vendored Normal file
View file

@ -0,0 +1,131 @@
---
title: Perplexed — directory templates
description: How the per-directory template system works in the perplexed plugin.
---
# Perplexed — directory templates
This folder is the home of **directory templates** used by the perplexed plugin's `Apply directory template…` commands. Each template is one markdown file with three zones — frontmatter, a fenced configuration block (`cft`), and a heading skeleton — and it tells perplexed how to fill out a category of vault files (concepts, vocabulary, sources, etc.) using Perplexity research.
The plugin seeds this folder on first run with the four templates below. You can edit them, delete them, add your own — perplexed only re-seeds when the folder is missing or empty (or when you click **Re-seed templates** in settings, which only fills in templates whose filenames don't already exist).
## Shipped templates
| File | Targets paths | Use for |
|---|---|---|
| `concept-profile.md` | `concepts/**` | Encyclopedia-style entries on ideas, patterns, mental models. Anti-incumbent editorial stance baked in. |
| `vocabulary-profile.md` | `Vocabulary/**` | Definitions of terms with disambiguation through an innovation-consulting lens. |
| `source-profile.md` | `Sources/**` | Profiles of trusted sources — books, people, channels, publications, journals, reports, events. Adapts emphasis to the source's type. |
| `toolkit-profile.md` | `Tooling/**` | Profiles of tools, products, platforms, frameworks. |
## How a template works
A template file has three zones:
```markdown
---
title: My Template
applies-to-paths:
- "MyDir/**"
description: One-line description.
---
# Free-form intro
Anything before the cft block is ignored — it's documentation for you.
` ` `cft
provider: perplexity
model: sonar-pro
return-citations: true
return-images: true
system: |
System prompt for the model. Can use {{basename}}, {{frontmatter}},
{{title}}, {{today}}, or {{frontmatter.<key>}}.
` ` `
# Heading 1 — first section
- Bullet instructions tell the model what to write here.
# Heading 2 — next section
- More bullet instructions.
***
# User Notes
Anything below the `***` line is excluded from the request.
```
### The three zones
1. **Frontmatter** (top, between `---` lines) — carries `title`, `applies-to-paths` (array of glob patterns), and an optional `description`. The plugin uses `applies-to-paths` to match a template to a target file.
2. **`cft` block** (a code fence with language `cft`) — YAML config: `provider`, `model`, `search-recency`, `return-citations`, `return-images`, plus a multi-line `system:` prompt. Anything above the `cft` block is treated as documentation and dropped from the request.
3. **Heading skeleton** (everything between the `cft` block's closing fence and the first `***`) — the user prompt. This is the markdown structure the model fills in. Bullets under each heading are *instructions to the model*, not literal output.
The `***` divider terminates the user prompt. Anything below it (the User Notes zone) is for your own scratch work and never reaches the model.
### Interpolation tokens
These tokens get replaced before the request is sent:
| Token | Resolves to |
|---|---|
| `{{basename}}` | The target file's filename without extension. **Use this for the H1**, never the frontmatter `title`. |
| `{{title}}` | The frontmatter `title` field, or the basename if missing. |
| `{{today}}` | Today's date in `YYYY-MM-DD` form. |
| `{{frontmatter}}` | The whole frontmatter (whitelisted keys only) as YAML. Useful to give the model existing context. |
| `{{frontmatter.<key>}}` or `{{<key>}}` | A specific frontmatter value (string, number, or array joined by commas). |
The frontmatter whitelist is configurable in plugin settings (default: `title, og_description, tags, og_image`). Only whitelisted keys are interpolated into prompts.
## Invoking a template
Three commands in the Obsidian command palette:
- **Apply directory template to current file** — picks the template that matches the active file's path via `applies-to-paths`, fills the body, stamps `cf_last_run` and `cf_last_run_model` to frontmatter.
- **Apply directory template to folder** — runs the matched template across every markdown file under a chosen folder. Stops on Cancel.
- **Stop directory template batch** — stops a running folder batch.
Modes:
- **Fill** — runs when the target file's body is empty or whitespace-only. The template fills the empty body.
- **Append** — runs when the target file already has content. The template's output is appended after the existing body and before a new sources footer.
## Image markers and fallback
When a template sets `return-images: true`, the runtime auto-appends an instruction telling the model to insert `[IMAGE N: <description>]` markers in its prose where images would help. After streaming completes:
1. The runtime swaps each `[IMAGE N: …]` for `![desc](image_url)` using Perplexity's returned `images` array.
2. If the model didn't emit any markers (or if the regex misses) but Perplexity *did* return images, they're appended as a `# Images` section before the sources footer.
3. If Perplexity returned no images at all, you'll see a console warning explaining that the model/query combination didn't yield images. The image-placeholder bullet in the template body remains a manual fallback (run **Find images for selection** on the section).
Image quality is best with `sonar-pro`. `sonar-deep-research` does not reliably return images.
## Citation behavior
Templates auto-prepend a system directive that tells the model to inline numeric `[N]` citation markers tied 1:1 to Perplexity's returned search results. After streaming, the runtime appends a `# Sources` footer using the canonical Lossless `[N]: [Title](URL)` format. cite-wide's `Convert All Citations to Hex Format` command will swap the numeric markers for hex IDs.
## Frontmatter stamps
Every successful run stamps:
- `cf_last_run` — ISO timestamp of the run.
- `cf_last_run_model``Provider model` label (e.g., `Perplexity sonar-pro`).
Books additionally get `google_books_url` stamped if the model surfaces a Google Books URL in body content and the field isn't already in frontmatter.
## Writing your own template
Copy any shipped template, change:
- The frontmatter `title`, `description`, and `applies-to-paths`.
- The `cft` block's `system:` prompt to your domain.
- The heading skeleton to the structure you want the model to fill.
Save it as `<your-name>-profile.md` in this folder. The plugin picks it up on the next palette invocation — no reload needed.
`applies-to-paths` accepts an array of glob patterns. `**` matches any depth. The first template whose patterns match the target file's path wins; specificity isn't ranked, so don't write overlapping patterns across templates.
## Re-seeding
The plugin only auto-seeds when this folder is missing or empty. To pull in a fresh shipped template (e.g., after a plugin update added one), open plugin settings → **Directory templates** → click **Re-seed templates**. That command only writes files whose filenames don't already exist; your edits to existing templates are never overwritten. To reset a template to its shipped default, delete the file first, then click Re-seed.

View file

@ -0,0 +1,111 @@
import type { App } from 'obsidian';
import { Notice, normalizePath, TFolder } from 'obsidian';
import readmeContent from '../docs/templates/README.md';
import conceptProfile from '../docs/templates/concept-profile.md';
import vocabularyProfile from '../docs/templates/vocabulary-profile.md';
import sourceProfile from '../docs/templates/source-profile.md';
import toolkitProfile from '../docs/templates/toolkit-profile.md';
interface SeedFile {
name: string;
content: string;
}
const SEED_FILES: SeedFile[] = [
{ name: 'README.md', content: readmeContent },
{ name: 'concept-profile.md', content: conceptProfile },
{ name: 'vocabulary-profile.md', content: vocabularyProfile },
{ name: 'source-profile.md', content: sourceProfile },
{ name: 'toolkit-profile.md', content: toolkitProfile },
];
async function ensureFolder(app: App, folderPath: string): Promise<void> {
const normalized = normalizePath(folderPath);
const existing = app.vault.getAbstractFileByPath(normalized);
if (existing) return;
// Walk parents and create each missing segment so a nested templatesRoot
// like `zz-cf-lib/templates` works on a fresh vault.
const segments = normalized.split('/').filter(s => s.length > 0);
let cursor = '';
for (const seg of segments) {
cursor = cursor ? `${cursor}/${seg}` : seg;
if (!app.vault.getAbstractFileByPath(cursor)) {
await app.vault.createFolder(cursor);
}
}
}
function folderHasMarkdown(app: App, folderPath: string): boolean {
const normalized = normalizePath(folderPath);
const folder = app.vault.getAbstractFileByPath(normalized);
if (!(folder instanceof TFolder)) return false;
return folder.children.some(child => child.path.endsWith('.md'));
}
/**
* Seed shipped templates into the configured templates root if the folder is
* missing or contains no markdown. Idempotent never overwrites an existing
* file. Designed for first-run experience: a fresh perplexed install creates
* `zz-cf-lib/templates/` populated with the four shipped templates and a
* README so the user has working templates without copy/paste.
*/
export async function seedTemplatesIfMissing(
app: App,
templatesRoot: string,
options: { quiet?: boolean } = {},
): Promise<{ seeded: number; reason: 'missing' | 'empty' | 'skipped' }> {
const quiet = options.quiet === true;
const normalized = normalizePath(templatesRoot);
const existing = app.vault.getAbstractFileByPath(normalized);
if (existing && folderHasMarkdown(app, normalized)) {
return { seeded: 0, reason: 'skipped' };
}
const reason: 'missing' | 'empty' = existing ? 'empty' : 'missing';
await ensureFolder(app, normalized);
let seeded = 0;
for (const file of SEED_FILES) {
const path = `${normalized}/${file.name}`;
if (app.vault.getAbstractFileByPath(path)) continue;
await app.vault.create(path, file.content);
seeded++;
}
if (!quiet && seeded > 0) {
new Notice(`Perplexed: seeded ${seeded.toString()} template${seeded === 1 ? '' : 's'} at ${normalized}`);
}
return { seeded, reason };
}
/**
* Force-seed: write every shipped file whose filename doesn't already exist.
* For users who want to pull in a newly-added shipped template after a plugin
* update without losing edits to existing templates. Bound to the "Re-seed
* templates" settings button.
*/
export async function reSeedMissingFiles(
app: App,
templatesRoot: string,
): Promise<{ seeded: number; skipped: number }> {
const normalized = normalizePath(templatesRoot);
await ensureFolder(app, normalized);
let seeded = 0;
let skipped = 0;
for (const file of SEED_FILES) {
const path = `${normalized}/${file.name}`;
if (app.vault.getAbstractFileByPath(path)) {
skipped++;
continue;
}
await app.vault.create(path, file.content);
seeded++;
}
new Notice(
`Perplexed: seeded ${seeded.toString()}, skipped ${skipped.toString()} (already present) at ${normalized}`,
);
return { seeded, skipped };
}

4
src/types/markdown.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*.md' {
const content: string;
export default content;
}