Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions docs/COMPILE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,70 @@ Saves the manuscript as Markdown note in your vault. Options:
| Output Path | Text | manuscript.md | Path relative to your project at which to save your compiled manuscript. $1, if present, will be replaced with your project’s title. |
| Open Compiled Manuscript | Boolean | true | If checked, open the compiled manuscript in a new pane. |

#### Add Zenodo Frontmatter

_Manuscript_

Reads a [Zenodo deposition](https://developers.zenodo.org/#representation)–style metadata JSON from your project folder (or its `source/` subfolder) and prepends a Pandoc-compatible YAML frontmatter to the manuscript. Keeping the metadata in Zenodo's schema means the same file can be uploaded to Zenodo when archiving your work. Options:

| Name | Type | Default | Description |
| --------------------- | ------- | ------------- | ------------------------------------------------------------------------------------------------------------ |
| Metadata file | Text | metadata.json | Filename of the Zenodo metadata JSON in your project folder. Trailing `.json` is optional. |
| Error on missing file | Boolean | true | If checked, throw when the metadata file is not found. Otherwise pass the manuscript through unchanged. |

The metadata file follows Zenodo's deposition schema for fields like `title`, `publication_date`, `description`, `creators[]`, `contributors[]`, `keywords[]`, `journal_title`, and `version`. Plugin-specific keys (Pandoc template, citation style, line numbering, multiple affiliations per author, corresponding-author flags, free-form extra YAML) live under a `_longform` namespace that Zenodo will ignore on upload. Example:

```json
{
"title": "A Study",
"publication_date": "2026-05-03",
"description": "An abstract.",
"creators": [
{ "name": "Doe, Jane", "affiliation": "Org A", "orcid": "0000-0000-0000-0000" },
{ "name": "Roe, Rick", "affiliation": "Org B" }
],
"keywords": ["alpha", "beta"],
"journal_title": "Nature",
"version": "v1.0",
"_longform": {
"acronym": "STUDY",
"csl": "nature",
"template": "default",
"lineno": false,
"figures_at_end": false,
"author_affiliations": { "Doe, Jane": ["Org A", "Org C"] },
"corresponding": ["Roe, Rick"],
"extra_yaml": "numbersections: true\n"
}
}
```

The step derives Pandoc's indexed `affiliations:` table from `creators[].affiliation` (or `_longform.author_affiliations[name]` when an author belongs to more than one institution), in order of first appearance. `title` and `creators` are required; the step throws if either is missing.

#### Replace JSON Placeholders

_Manuscript_

Replaces `{{ path.to.value }}` placeholders in your manuscript with values resolved from a JSON file in your project folder (or its `source/` subfolder). Useful for injecting computed numerical results, dates, or any other values produced outside Obsidian. Options:

| Name | Type | Default | Description |
| ---------------- | ------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| JSON file | Text | results.json | Filename of the JSON data file. Trailing `.json` is optional. |
| Start delimiter | Text | `{{` | Left delimiter of placeholders. |
| End delimiter | Text | `}}` | Right delimiter of placeholders. |
| Error on missing | Boolean | false | If checked, throw when a placeholder path is not found in the JSON file. Otherwise the placeholder is left unchanged in the output. |

Path expressions support dot and bracket notation in any combination (`a.b.c`, `a.b[0].c`). Object values are stringified as JSON, `null` becomes the empty string. Example `results.json`:

```json
{
"summary": { "n": 42, "mean": 3.14 },
"samples": [{ "id": "S-01" }, { "id": "S-02" }]
}
```

The manuscript text `We collected {{ summary.n }} samples (first: {{ samples[0].id }}).` becomes `We collected 42 samples (first: S-01).`.

### User Script Steps

In addition to the built-in steps above, Longform also supports user script steps, which are arbitrary JavaScript scripts that can be loaded and used like any other step.
Expand Down
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "longform",
"name": "Longform",
"version": "2.1.0",
"version": "2.2.0-beta.1",
"minAppVersion": "1.0",
"description": "Write novels, screenplays, and other long projects in Obsidian.",
"author": "Kevin Barrett",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "longform",
"version": "2.1.0",
"version": "2.2.0-beta.1",
"description": "Write novels, screenplays, and other long projects in Obsidian (https://obsidian.md).",
"main": "main.js",
"scripts": {
Expand Down
155 changes: 155 additions & 0 deletions src/compile/steps/add-zenodo-frontmatter-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
export interface ZenodoCreator {
name: string;
affiliation?: string;
orcid?: string;
gnd?: string;
}

export interface ZenodoContributor extends ZenodoCreator {
type?: string;
}

export interface LongformExtras {
acronym?: string;
csl?: string;
template?: string;
lineno?: boolean;
figures_at_end?: boolean;
author_affiliations?: Record<string, string[]>;
corresponding?: string[];
extra_yaml?: string;
}

export interface ZenodoMetadata {
title?: string;
publication_date?: string;
description?: string;
creators?: ZenodoCreator[];
contributors?: ZenodoContributor[];
keywords?: string[];
journal_title?: string;
version?: string;
_longform?: LongformExtras;
}

/**
* Build a Pandoc-style YAML frontmatter from a Zenodo deposition metadata
* object. Returns the body of the frontmatter (no surrounding `---` lines)
* and always ends with a newline.
*/
export function buildPandocYaml(metadata: ZenodoMetadata): string {
if (!metadata || typeof metadata !== "object") {
throw new Error("[Add Zenodo Frontmatter] Metadata must be a JSON object.");
}
if (!metadata.title || typeof metadata.title !== "string") {
throw new Error(
"[Add Zenodo Frontmatter] Metadata is missing required field 'title'."
);
}
if (
!Array.isArray(metadata.creators) ||
metadata.creators.length === 0 ||
metadata.creators.some((c) => !c || typeof c.name !== "string" || !c.name)
) {
throw new Error(
"[Add Zenodo Frontmatter] Metadata is missing required field 'creators' (non-empty array of {name, ...})."
);
}

const ext = metadata._longform ?? {};
const date =
metadata.publication_date && metadata.publication_date.length > 0
? metadata.publication_date
: new Date().toISOString().slice(0, 10);

const correspondingSet = new Set(ext.corresponding ?? []);
const authorAffiliations = ext.author_affiliations ?? {};

const affiliationIndex: string[] = [];
const indexFor = (name: string): number => {
const i = affiliationIndex.indexOf(name);
if (i >= 0) return i + 1;
affiliationIndex.push(name);
return affiliationIndex.length;
};

type AuthorOut = {
name: string;
affiliationIndices: number[];
corresponding: boolean;
};
const authorsOut: AuthorOut[] = metadata.creators.map((creator) => {
const explicit = authorAffiliations[creator.name];
const affilNames =
explicit && explicit.length > 0
? explicit
: creator.affiliation
? [creator.affiliation]
: [];
return {
name: creator.name,
affiliationIndices: affilNames.map(indexFor),
corresponding: correspondingSet.has(creator.name),
};
});

const lines: string[] = [];
lines.push(`title: ${yamlString(metadata.title)}`);
lines.push(`date: ${yamlString(date)}`);

lines.push("authors:");
for (const a of authorsOut) {
lines.push(` - name: ${yamlString(a.name)}`);
if (a.affiliationIndices.length > 0) {
lines.push(` affiliation: [${a.affiliationIndices.join(", ")}]`);
}
if (a.corresponding) {
lines.push(` corresponding: ${yamlString("yes")}`);
}
}

if (affiliationIndex.length > 0) {
lines.push("affiliations:");
affiliationIndex.forEach((name, i) => {
lines.push(` - index: ${i + 1}`);
lines.push(` name: ${yamlString(name)}`);
});
}

lines.push(`abstract: ${yamlString(metadata.description ?? "")}`);

if (Array.isArray(metadata.keywords) && metadata.keywords.length > 0) {
lines.push("keywords:");
for (const k of metadata.keywords) {
lines.push(` - ${yamlString(String(k))}`);
}
}

lines.push(`target: ${yamlString(metadata.journal_title ?? "")}`);
lines.push(`acronym: ${yamlString(ext.acronym ?? "")}`);
lines.push(`csl: ${yamlString(ext.csl ?? "")}`);

if (ext.template) {
lines.push(`template: ${yamlString(ext.template)}`);
}
if (ext.lineno) {
lines.push(`lineno: ${yamlString("true")}`);
}
if (ext.figures_at_end) {
lines.push(`figures-at-end: ${yamlString("true")}`);
}

let body = lines.join("\n") + "\n";
if (ext.extra_yaml && ext.extra_yaml.length > 0) {
const extra = ext.extra_yaml.endsWith("\n")
? ext.extra_yaml
: ext.extra_yaml + "\n";
body += extra;
}
return body;
}

/** Quote a value as a YAML double-quoted string, escaping `\` and `"`. */
function yamlString(s: string): string {
return `"${String(s).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
110 changes: 110 additions & 0 deletions src/compile/steps/add-zenodo-frontmatter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { TFile } from "obsidian";
import type { CompileContext, CompileManuscriptInput } from "..";
import {
CompileStepKind,
CompileStepOptionType,
makeBuiltinStep,
} from "./abstract-compile-step";
import {
buildPandocYaml,
type ZenodoMetadata,
} from "./add-zenodo-frontmatter-utils";

export const AddZenodoFrontmatterStep = makeBuiltinStep({
id: "add-zenodo-frontmatter",
description: {
name: "Add Zenodo Frontmatter",
description:
"Reads a Zenodo-style metadata JSON from your project folder and prepends a Pandoc-compatible YAML frontmatter to the manuscript.",
availableKinds: [CompileStepKind.Manuscript],
options: [
{
id: "metadata-file",
name: "Metadata file",
description:
"Filename of the Zenodo deposition metadata JSON in your project folder (or its 'source/' subfolder). Trailing '.json' is optional.",
type: CompileStepOptionType.Text,
default: "metadata.json",
},
{
id: "error-on-missing-file",
name: "Error on missing file",
description:
"If checked, throw an error when the metadata file is not found. Otherwise pass the manuscript through unchanged.",
type: CompileStepOptionType.Boolean,
default: true,
},
],
},
async compile(
input: CompileManuscriptInput,
context: CompileContext
): Promise<CompileManuscriptInput> {
if (context.kind !== CompileStepKind.Manuscript) {
throw new Error("Cannot add frontmatter to non-manuscript.");
}

const metaFileName = String(
context.optionValues["metadata-file"] ?? "metadata.json"
).trim();
const errorOnMissingFile = Boolean(
context.optionValues["error-on-missing-file"] ?? true
);

const baseName = metaFileName.endsWith(".json")
? metaFileName
: `${metaFileName}.json`;

const candidatePaths = [
`${context.projectPath}/${baseName}`,
`${context.projectPath}/source/${baseName}`,
];

let file: TFile | null = null;
let foundPath = "";
for (const path of candidatePaths) {
const f = context.app.vault.getAbstractFileByPath(path);
if (f instanceof TFile) {
file = f;
foundPath = path;
break;
}
}

if (!file) {
if (errorOnMissingFile) {
throw new Error(
`[Add Zenodo Frontmatter] Metadata file not found at ${candidatePaths.join(
" or "
)}`
);
}
return input;
}

const raw = await context.app.vault.cachedRead(file);
let metadata: ZenodoMetadata;
try {
metadata = JSON.parse(raw) as ZenodoMetadata;
} catch (e) {
throw new Error(
`[Add Zenodo Frontmatter] Invalid JSON in ${foundPath}: ${
(e as Error).message
}`
);
}

const yaml = buildPandocYaml(metadata);
return {
contents: `---\n${yaml}---\n\n${input.contents}`,
};
},
});

export {
buildPandocYaml,
type ZenodoMetadata,
type ZenodoCreator,
type ZenodoContributor,
type LongformExtras,
} from "./add-zenodo-frontmatter-utils";
4 changes: 4 additions & 0 deletions src/compile/steps/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,18 @@ import { RemoveStrikethroughsStep } from "./remove-strikethroughs";
import { StripFrontmatterStep } from "./strip-frontmatter";
import { WriteToNoteStep } from "./write-to-note";
import { AddFrontmatterStep } from "./add-frontmatter";
import { AddZenodoFrontmatterStep } from "./add-zenodo-frontmatter";
import { ReplaceJsonPlaceholdersStep } from "./replace-json-placeholders";

export const BUILTIN_STEPS = [
AddFrontmatterStep,
AddZenodoFrontmatterStep,
ConcatenateTextStep,
PrependTitleStep,
RemoveCommentsStep,
RemoveLinksStep,
RemoveStrikethroughsStep,
ReplaceJsonPlaceholdersStep,
StripFrontmatterStep,
WriteToNoteStep,
];
Loading
Loading