Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

33 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ditto

ditto builds plain-text sources into the Microsoft Office and HTML deliverables people expect, the way cargo builds a crate. You author Markdown, CSV, and other text under src/; ditto presses each file into its Word, Excel, PowerPoint, or portable-HTML counterpart under dist/; and an optional publish step mirrors dist/ to a SharePoint library or a local folder. The text is the master you version; the Office files are the impression you hand to people who live in Word and Excel.

The name is the ditto machine, the old spirit duplicator: the source and the built document are two pulls of the same master, not a copy.

ditto reimplements nothing. It conducts tools that already exist — office-convert for Markdown and CSV conversion, cleave for portable HTML, and xsync for the SharePoint push. Install whichever of those you need; ditto warns about what is missing.

The manifest is authoritative

Ditto.toml lists every deliverable. If a file is not listed, it is not built — there is no implicit globbing. Each target names an input under src/, an output under dist/, and optionally a style template, a view, or a custom converter.

The manifest also marks the project root. build, scan, and publish walk up from wherever you are to the nearest Ditto.toml, so they work from inside src/ or any other subdirectory rather than only at the top. Paths in the output stay relative to the root; when that is not where you are standing, ditto says which directory it settled on.

[project]
name = "parsolvo-assessment"

[publish]                              # optional; omit to skip publishing
root = "https://parsolvo.sharepoint.com/sites/Engagement/Shared Documents/Deliverables"

[[target]]
input = "assessment.md"                # relative to src/
output = "deliverable1/drafts/Assessment.docx"   # relative to dist/
reference = "brand/excelano.docx"      # optional styling template

[[target]]
input = "inventory.csv"
output = "deliverable1/supporting/Asset Inventory.xlsx"

The output extension picks the converter, so the same Markdown file can build a Word document in one target and a slide deck in another just by naming .docx in one and .pptx in the other.

When a project has a house style, most .docx targets want the same reference doc and most .pptx targets the same slide template. Set them once at the [project] level with default_reference_docx and default_reference_pptx rather than repeating reference on every target; a target's own reference still overrides the default.

[project]
name = "parsolvo-assessment"
default_reference_docx = "brand/excelano.docx"
default_reference_pptx = "brand/excelano.pptx"

src/ is fixed — it is the curated master tree, and scan reports anything in it that no target covers, so it must hold only real sources. The output side is not fixed: set dist at the [project] level to build and publish through a directory other than the default dist/ (a ~ is expanded), for instance an external staging folder outside the repo. A dist that resolves inside src/ is rejected, since building there would write outputs into the source tree.

[project]
name = "parsolvo-assessment"
dist = "~/austin-contract2-stage"
Input Output Engine
.md .docx office-convert (pandoc)
.md .pptx office-convert (pandoc)
.csv .xlsx office-convert (openpyxl)
several .csv .xlsx office-convert (one sheet per CSV)
.md / .csv / .ics .html cleave

For anything the built-ins do not cover, set converter on the target to your own script. It is called as converter <input> <output> with the output's parent directories already created; a non-zero exit fails that target. Any reference and view are passed to it as the REFERENCE_DOC and VIEW environment variables.

The script path is resolved relative to the project root, so build.py and converters/build.py both refer to a file in the project; a bare name not found there falls through to a $PATH lookup. The script is run directly, so it must be executable (chmod +x) and start with a shebang like #!/usr/bin/env python3. The same holds for pipeline scripts below.

When one deliverable is assembled from several sources — say a multi-tab workbook built from three CSVs — list them all with inputs instead of input. The first path is still passed as <input>, the positional argument, and the whole set is handed to the converter as the INPUTS environment variable, one absolute path per line. The positional <input> and <output> are relative to the project root, while INPUTS lines are absolute, so a converter that changes its working directory should read sources from INPUTS rather than the positional argument. Naming every source here keeps the manifest honest about what the deliverable depends on, and stops ditto scan from reporting the extra files as uncovered. inputs pairs with a custom converter for a deliverable with real presentation logic; the one built-in that takes several inputs is .xlsx, which turns several CSVs into a sheet each with no converter (below).

[[target]]
inputs = [
  "contracts_enriched.csv",
  "contracts_expired.csv",
  "contract_monthly_summary.csv",
]
output = "deliverable3/Contract Action Calendar.xlsx"
converter = "converters/build_calendar.py"

When several CSVs just need to land as separate tabs with no extra logic — each already final, no coloring or derived columns — point inputs at them with an .xlsx output and no converter. The built-in writes one sheet per CSV, named after the input filename stem, in the order listed. Anything fancier than plain tabs stays a custom converter.

[[target]]
inputs = ["active.csv", "expired.csv"]
output = "Asset Register.xlsx"

Often the real source of a deliverable is further upstream than the file the converter reads: a raw export gets pulled, then classified and enriched, and only the result is ready to press into a workbook. List those upstream steps with pipeline and ditto runs them in order before the converter, each with no arguments, failing the target on the first non-zero exit. They run before the inputs are checked for existence, because they are what produce those inputs, so ditto build reproduces the whole derivation rather than just the last hop. A pipeline belongs to its target and runs whenever that target builds; two targets that share the same pipeline each run it. A target with a pipeline is never considered up to date, because the pipeline produces its inputs from somewhere ditto cannot see, so their timestamps say nothing about whether the output is current.

[[target]]
pipeline = ["extract_contracts.py", "enrich_contracts.py"]
input = "contracts_enriched.csv"
output = "deliverable3/Contract Action Calendar.xlsx"
converter = "converters/build_calendar.py"

Commands

ditto new <name>      # scaffold src/, dist/, Ditto.toml, .gitignore
ditto init            # scaffold a project around the files already here
ditto check           # validate the manifest without building anything
ditto build           # build every target whose output is out of date
ditto build <prefix>  # build only targets whose output is under <prefix>/
ditto build --force   # rebuild every target, up to date or not
ditto build -n        # list what would be rebuilt, without converting anything
ditto scan            # report files in src/ that no target covers
ditto scan --write    # append a target per uncovered file, using defaults
ditto clean           # remove the built deliverables in dist/
ditto clean -n        # list what clean would remove, without removing it
ditto publish         # mirror dist/ to the publish root
ditto publish <prefix>  # mirror only dist/<prefix>/ to <root>/<prefix>/
ditto publish -n      # preview the publish without writing anything
ditto publish --delete  # also remove root files no longer in dist/

Every command explains itself: ditto <command> --help gives its arguments, its flags, and what it is for.

new creates a project; init adopts one. Point init at a folder that already holds your sources and it writes the manifest, adds dist/ to an existing .gitignore rather than replacing it, and leaves whatever is already in src/ alone. Follow either with ditto scan --write.

check answers "will this build?" without converting anything: every input exists, no two targets write the same output, every reference and pipeline script is where the manifest says, every output extension maps to a converter, and every converter that implies — md2docx, csv2xlsx, cleave — is installed on this machine. It also names files sitting in dist/ that no target produces. Run it after hand-editing the manifest, and on any machine that has to build the project but may not have the same tools.

It answers "will this publish?" too. SharePoint and OneDrive are case-preserving but case-insensitive, so outputs that differ only in case are two paths locally and one item in the library: two such files overwrite each other on upload with nothing reported, and two such folders merge. This is easy to create without noticing, because scan --write names outputs after the src/ tree — a manifest with a hand-named D3/… target and a scanned d3/… one asks for a folder the library cannot give you twice. check reports the clash as an error when the manifest has an https:// publish root, and as a note otherwise.

On an engagement with many deliverables, a single manifest holds dozens of targets but you are usually iterating on one. build and publish take an optional prefix — the leading segment of an output path — to work on just that slice: ditto build D3 builds only the targets whose output is under D3/, and ditto publish D3 mirrors only dist/D3/ to the matching D3/ subfolder of the root, so neither one pays the cost of touching every other deliverable. The prefix matches on a path boundary, so D3 selects D3/… without also catching D30/….

Within that selection, build skips targets that are already up to date, the way cargo skips crates that have not changed. A target is rebuilt when its output is older than any of its inputs, its styling reference, or its converter script, and also when its own entry in the manifest has changed since it was last built — so editing a target's view or reference rebuilds it just as editing the source does, while adding a target, or renaming another one's output, leaves it alone. ditto build --force reconverts everything regardless, and ditto build -n answers which targets that decision selects without running a converter or a pipeline — the question worth asking before a --force across an engagement's worth of deliverables.

Answering that second question needs something on disk, because one timestamp on Ditto.toml says the file was edited and not which of its targets the edit touched. build therefore records a fingerprint of each entry it builds under .ditto/, alongside dist/ in the .gitignore that new and init write. The directory is ditto's bookkeeping and holds nothing you wrote; it sits at the project root rather than inside dist/ because publish mirrors dist/ verbatim to the destination, so anything kept there would ship with the deliverables. It is also deliberately weak: it can only ever hold a target back for rebuilding, never declare one current on its own, so deleting it costs one extra rebuild and nothing else. ditto clean removes it along with the outputs it describes.

Two things are deliberately outside that check. A target with a pipeline always rebuilds, because the pipeline exists to regenerate inputs from somewhere ditto cannot see and their timestamps prove nothing about whether the result is current. And converters found on $PATH are not tracked, so after upgrading pandoc or office-convert, run --force once to pick up the new output. When you want the guarantee rather than the speed, ditto clean && ditto build reproduces the whole set from source.

publish mirrors dist/ to the [publish] root: a SharePoint library (through xsync) or, when the root is a filesystem path, a plain recursive copy. Each target's output already names the destination subfolder and filename, so dist/ is the publish layout — there is no separate staging step.

One thing to expect on SharePoint: the library rewrites Office files on upload, binding them to a content type, so the stored bytes differ from what you sent and the remote size and hash never match your local copy again. The deliverable content is untouched, and it costs you nothing: xsync compares modification times and only looks at size once the timestamp has moved, so an unchanged .docx, .xlsx, or .pptx is skipped on the next publish. If you do see every Office file re-uploaded on every publish, cutting a new document version each time, your xsync predates that change — check whether xsync --help lists --itemize-changes, and upgrade if it doesn't. To find out why a particular file is being sent, run xsync --dry-run --itemize-changes against the same source and destination.

ditto scan with no flag only reports; --write appends a [[target]] for each uncovered file using the default output (.md to .docx, .csv to .xlsx), mirroring the src/ tree. It appends rather than rewriting, so your hand-edited names and comments are left alone. The formats it cannot guess — .md to a deck, anything to HTML, unknown extensions — it leaves for you.

ditto clean removes dist/. Nothing else does: build only ever writes, so renaming a target's output leaves the old file behind, and a later publish without --delete ships both the current deliverable and the stale one to the client library. check names those orphans and clean clears them. Because dist is configurable and ~ is expanded, clean refuses to remove the filesystem root, your home directory, or any directory containing the project.

Install

On Debian and Ubuntu, install from the Excelano apt repo so ditto updates with the rest of your system. Add the repo once, then install:

curl -fsSL https://excelano.com/apt/setup.sh | sudo sh
sudo apt install ditto

Anywhere else with a Linux binary, fetch the latest release:

curl -fsSL https://raw.githubusercontent.com/excelano/ditto/main/install.sh | sh

The script drops the binary into /usr/local/bin (or ~/.local/bin if that is not writable). Pin a version with DITTO_VERSION=v0.1.0 or choose the directory with DITTO_INSTALL_DIR. Uninstall with the matching uninstall.sh, or sudo apt remove ditto if you installed via apt.

To build from source (and on macOS, where the system already ships its own /usr/bin/ditto, so pick a different output name):

go build -o ~/bin/ditto .

ditto is a single Go binary with no runtime dependencies of its own; the converters it calls are separate packages. apt install ditto recommends both of them — office-convert, which supplies md2docx, md2pptx, and csv2xlsx and pulls in pandoc, and cleave, which bakes the portable HTML — so every built-in output format works out of the box. xsync is suggested rather than recommended, since it is needed only to publish; install it with apt install xfiles. ditto check names whichever are missing.

Claude Code skill

skills/ditto/ is a Claude Code skill so an AI coding agent drives ditto instead of hand-running the converters it orchestrates. Install it into your skills directory:

cp -r skills/ditto ~/.claude/skills/

The agent then reaches for ditto build/ditto publish on a manifest-governed deliverable set, and hands a genuine one-off conversion straight to the underlying converter.

About

Cargo-style build system for Office and HTML deliverables: author text, build to Word/Excel/PowerPoint/HTML, publish to SharePoint.

Resources

Security policy

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages