Home / Blog / Eleven Steps From a Build Awesome Site to a KC Works Deposit

I already wrote about running the KC Works importer and about what the whole process taught me. This post is the part in between, and it is the part that actually takes the time.

You have a journal on a static site. It builds, it looks fine, people read it. Now you want DOIs, which means you want a repository, which means a machine with strict standards is about to read your archive and tell you what is wrong with it. That machine does not care that the site looks fine.

Here is the plan I used on 829 articles, in the order I would do it again. Steps one through four happen in your site repo. Steps five through eleven happen in a second repo built for the deposit. Do not skip ahead. Every step after the first depends on the front matter being right, and fixing front matter after you deposit means fixing it in two places.

1. Get the front matter right first #

Everything downstream is generated from front matter. The PDF cover sheet, the RIS file, the CSL JSON, the Schema.org record, the OAI feed, and the repository deposit all read the same five or six fields. Write them once, correctly, and you never write them again.

For each article you need a title, a description, at least one author, a stable permalink, and a permanent id.

Title. The real title, not a shortened display version. Strip your own Markdown out of it before it goes into metadata, because a repository will happily store literal asterisks in a record title.

Description. This becomes the abstract in the deposit and the description in every citation export. Use the article's actual abstract when you have one. Write a real 150 to 160 character summary when you do not. Do not leave it empty and plan to come back, because you will deposit before you come back.

Authors. One field, and be disciplined about how you write names, because a repository wants them inverted and split. "Callan, Maeve" as the display name, then given name and family name separately. Decide now how you handle two authors, an editor credit, a translator, and an unsigned editorial. I let unsigned records fall back to a JCRT Editors creator rather than deposit a record with no author at all.

While you are in here, audit for duplicates. Three of my authors existed under two slugs each because their name appeared two ways across twenty years of issues. Merge them before you deposit, not after.

Permalink. This one bites people. In my setup the article's filename is its permalink, and the generator builds the record's canonical URL straight from the issue folder and the filename:

https://jcrt.org/archives/24.2/callan/

That URL goes into the deposit as a url identifier and onto the PDF cover sheet as the stable URL. Rename the file after you deposit and you have broken a link inside a record you can no longer easily edit. Settle your filenames first.

A permanent id, mapped to import-recid. This is the single highest-value thing in this post.

Give every content page a short permanent id in front matter. I use nanoid at six characters, URL-safe alphabet, quoted, on line two of every file. Generate them with a script, stage them automatically with a pre-commit hook, and make a check command fail the build when a page does not have one. Once assigned, an id never changes.

---
nanoid: "gzlGeC"
doi: "10.17613/s6j1w-s6z25"
title: "The Medieval Origins of Religious White Supremacy"
author: "Maeve Callan"
affiliation: "Simpson College"
volume: "24"
issue: "2"
pages: 260-273
date: 2026-03-03
pdf: "callan.pdf"
description: "Callan traces how medieval English myths, crusade defeat, and Irish colonization shaped Christian white supremacy."
keywords:
  - christianity
  - crusades
---

Your metadata generator then exports that id as an identifier with the scheme import-recid:

"identifiers": [
  { "identifier": "gzlGeC", "scheme": "import-recid" },
  { "identifier": "https://jcrt.org/archives/24.2/callan/", "scheme": "url" }
]

Now every deposited record carries a permanent pointer back to exactly one file in your repo. That makes the import resumable, it makes reconciliation possible, and it is how the returned DOIs find their way home. Build this before you need it. Retrofitting stable ids onto an archive you have already deposited is genuinely painful.

2. Add FAST and Homosaurus subjects #

Keywords are free text and repositories treat them as tags. Controlled subjects come from an authority file, and they are what makes your archive discoverable next to everyone else's.

I use two vocabularies. FAST is OCLC's faceted subject headings, derived from Library of Congress authorities. Homosaurus is an international LGBTQ+ linked data vocabulary that covers ground FAST handles badly or not at all.

In front matter, carry the full record rather than just a label:

subjects:
  - label: "Christianity"
    scheme: "FAST"
    identifier: "fst00859599"
    uri: "https://id.worldcat.org/fast/859599"
    category: "topical"
  - label: "Ireland"
    scheme: "FAST"
    identifier: "fst01205427"
    uri: "https://id.worldcat.org/fast/1205427"
    category: "geographic"

The category matters. FAST is faceted, so a heading is topical, geographic, corporate, form and genre, event, meeting, personal, title, or chronological. Your generator folds that facet into the scheme KC Works expects, producing FAST-topical, FAST-geographic, FAST-formgenre, and so on.

Two hard rules I learned by breaking them.

The authority URI has to match exactly, and it is not the form you looked it up in. KC Works keys its vocabularies on exact id strings. FAST's search interface will hand you https://id.worldcat.org/fast/859599 and the identifier fst00859599. What KC Works wants is http://id.worldcat.org/fast/859599. Note the http, and note that the fst form is an identifier rather than a URI and never gets exported at all. Homosaurus wants https://homosaurus.org/v3/homoit0000560.

Keep the human-readable form in front matter, where you can check it against the authority you looked it up in, and have the generator normalize on the way out. Then make it fail the build when it meets a URI or a facet it does not recognize, rather than passing the string through. A near miss does not error at import. It deposits a subject that matches nothing, and you find out much later.

Do not guess. I ran an audit and found surnames resolved to topical headings that happened to share a spelling. An author named Rose had been matched to the heading for roses. Wells got water wells. A wrong authority heading is worse than no heading, because it is confidently wrong and it propagates. When no record genuinely matches, leave the term as a plain keyword and move on.

Keywords still earn their place. They ride along as user-defined tags on the record, so you get free text and controlled vocabulary both.

3. ORCID and ISSN, in the right places #

These two identifiers get confused constantly, so be precise about what each one identifies.

ORCID identifies a person. It belongs to your author, not to your article, and it goes inside the creator object:

"person_or_org": {
  "type": "personal",
  "name": "Arnold, Philip P.",
  "given_name": "Philip P.",
  "family_name": "Arnold",
  "identifiers": [
    { "identifier": "0009-0008-1224-5714", "scheme": "orcid" }
  ]
}

Validate the format before you deposit. Four groups of four, last character a digit or an X. KC Works accepts orcid, isni, and kc_username as creator identifier schemes and nothing else.

Getting ORCID iDs is slower than it sounds. The public registry search at pub.orcid.org/v3.0/expanded-search/ answers anonymous requests, returns institution names inline, and needs no token. That gets you candidates. It does not get you matches. A name alone is not evidence, and matching on a name alone is how author records get corrupted. Require something more, and treat an existing publication in your journal on someone's ORCID record as the strongest signal you will get. I built the lookup so it produces a ranked report for a human to review and refuses to apply anything on its own. My coverage is currently 38 profiles out of 648, and asking authors directly remains faster than any script.

ISSN identifies the journal. It is not an article identifier, and this is the mistake I most want to save you. It belongs in the journal custom field:

"custom_fields": {
  "journal:journal": {
    "title": "Journal for Cultural & Religious Theory",
    "volume": "25",
    "issue": "1",
    "pages": "137-139",
    "issn": "1530-5228"
  }
}

Put the ISSN in metadata.identifiers and KC Works will accept it. It will simply be wrong, on every record, forever.

4. Build a cover sheet, and know why #

A cover sheet, or fly sheet, is the JSTOR-style page prepended to a PDF that carries the title, authors, journal name, ISSN, publisher, stable URL, and rights statement.

Be clear about this: KC Works does not require one. The repository stores your metadata as structured data. The deposit works with a bare PDF. Nothing in the import will fail because your PDF lacks a cover.

I built one for all 795 archive PDFs anyway, and here is the honest reason. A PDF leaves your site. Someone downloads it, mails it to a student, drops it in a shared folder, cites it three years later. At that point the file is the only artifact, and everything your website knew about it is gone. The cover sheet puts the citation, the canonical URL, and the license inside the object, so the file can identify itself when separated from every system that described it.

Two practical notes. Write the same values into the PDF metadata that you print on the page, then add a bookmark outline so the cover and article are navigable. And test it: my check compares the visible text on page one against the embedded /Title, /Author, and /Permalink and fails when they disagree.

Know the limit of what this achieves. A cover sheet makes a file citable and self-identifying. It does not make a 2003 page scan accessible. Those are different projects and you should not let a passing cover-sheet test convince you otherwise.

5. Getting ready for import #

Stop and take inventory before you build anything else. For each issue you need:

  • every published article's front matter complete, including the id from step one,
  • a PDF for every article, including the ones that never had one,
  • your subjects verified,
  • your filenames final.

That third item catches people. I had 103 articles published as web pages only, with no PDF of record. I generated those from their Markdown through Pandoc into LuaLaTeX with a journal template. It takes about fifteen seconds per article and it is worth doing before deposit rather than after, because a record deposited without a file is a record you cannot easily complete later.

Decide your scope now, too. My numbered archive is in scope. Blog posts and editorial announcements are not, at least not yet. Draw that line before you start rather than partway through.

6. Create the import directory #

Make a second repository. Mine is jcrt-meta, and it sits beside the site repo as a sibling so scripts can reach ../jcrt-v2 for canonical front matter.

The layout is deliberately boring. One flat directory, two files per issue:

archives/
  24.2.metadata.json
  24.2.zip
  25.1.metadata.json
  25.1.zip

The JSON is an array of record objects, one per article, generated from your site's front matter. The ZIP holds that issue's PDFs. The filenames inside the ZIP must exactly match the file entries named in the metadata, because that is how the importer pairs them.

Rules that keep this repo sane:

No hidden files in the ZIPs. macOS puts .DS_Store everywhere. Windows adds its own. Those files end up as entries in your deposit and they look exactly as unprofessional as they sound. Build the ZIP from an explicit list of PDFs rather than zipping a directory, and check the entry list afterward.

Do not commit expanded PDFs. The ZIP is the artifact. Extract to a temporary directory when you need to validate, and keep archives/ flat.

Exclude what should not be deposited. My table of contents PDF stays out. It is a navigation aid, not a work, and it does not need a DOI.

Generate, then verify. I have a generate command that rebuilds every sidecar from current front matter and a check command that fails when a committed sidecar has drifted. Run check in CI. Front matter changes in the site repo, and without that check your deposit metadata quietly falls behind the site.

7. Autogenerate the collection image #

This one is pure quality of life, and I recommend it.

Each issue gets its own KC Works collection, and each collection can carry a profile image. Sixty-seven collections is enough that you will lose your place. A generated cover with the issue number printed on it means you can see at a glance which issues are done, which are half done, and which you have not started.

I keep one SVG template with the issue number in a text element, then render and convert it:

rsvg-convert -w 800 -h 800 -o issue.png images/jcrt-issue.svg
magick issue.png -background white -alpha remove -alpha off -strip \
  -units PixelsPerInch -density 72 24.2.profile.png

The requirements are specific, so validate rather than hope. 800 by 800 pixels, 72 DPI, under 1 MiB, and no alpha channel. My script checks the rendered file's dimensions and DPI before upload and refuses to continue when they are wrong. It also checks that the number in the SVG matches the issue being built, because rendering issue 24.2's cover for issue 25.1 is a mistake you make exactly once and then never notice again.

8. Prepare the Python importer #

The importer is kcworks_api_importer.py from the KC Works repository. Vendor it into your import repo so your process does not change under you when upstream changes.

It needs Python 3.9 or later and requests:

python3 -m venv .venv
.venv/bin/pip install requests

Download the raw file. I saved the GitHub HTML page instead and spent a while investigating a SyntaxError: invalid character '·' (U+00B7) that came from a page title rather than from my data.

I made exactly one change to the vendored copy: it now exits non-zero after a non-201 response. The original prints the failure clearly for a human reading the terminal, but a wrapper script that checks exit codes needs the exit code to be true. If you wrap this thing, make that change or your wrapper will cheerfully report success on a partial server failure.

9. Make the .env #

The script reads environment variables. It does not read a .env file. That distinction matters, because the fix looks like .env support and is not:

set -a
source .env
set +a

set -a exports everything assigned after it, so sourcing the file puts those values into the environment the script inherits.

My whole .env is two lines:

KCWORKS_IMPORT_API_KEY=…
KCWORKS_IMPORT_OUTPUT_PATH=_logs/{issue}.log

The {issue} placeholder is my convention, not the script's. My wrapper substitutes the issue number so each import writes its own log.

Gitignore this file. Keep the key out of command-line arguments so it stays out of shell history. Get the token from works.hcommons.org/account/settings/applications/, and copy it when it appears, because you do not get to see it again.

10. Read both documentation sets together #

Neither set of documentation is complete on its own, and knowing which one answers which question saves hours.

The MESH KC Works documentation covers the import API, the importer script, and the metadata reference. This is where you learn what a record looks like, what import-recid does, and what the importer accepts. It is also where the import API's limits are stated plainly: it creates records, it does not update them, and a re-import of an already-registered identifier returns a 409 Conflict rather than replacing anything.

The InvenioRDM documentation covers everything underneath. KC Works runs on InvenioRDM, so collections, versions, drafts, files, and access all behave the way InvenioRDM behaves. Translate the vocabulary as you read. A KC Works collection is an InvenioRDM community, and every API path says communities.

The practical rule: MESH tells you how to deposit, InvenioRDM tells you how the thing you deposited into works. When you want to create sixty-seven collections without clicking sixty-seven times, MESH does not cover it and InvenioRDM does.

One fact worth reading in advance rather than discovering. Published records lock their files. You cannot swap a PDF on a published record. Your options are a new version, which mints a new version DOI, or an administrator. Check your files before you publish.

11. Build the shell script, and let it do the repetition #

Now tie it together. My wrapper is bash calling the vendor's Python, because I read and debug bash and I want to be able to fix this at 11pm.

./import.sh 25.1 does the following, in this order:

  1. validates that the sidecar and ZIP both exist,
  2. renders and checks the profile image,
  3. prints the issue, collection URL, record count, and every article title with its file,
  4. runs a local preflight,
  5. asks before creating the collection,
  6. creates or reuses it, tags it as type journal, and sets your ownership public,
  7. asks before uploading the profile image,
  8. asks before importing,
  9. runs the importer,
  10. syncs the returned DOIs back into your site's front matter.

./import.sh --check 25.1 runs steps one through four and stops. Nothing leaves the machine. That is the command I ran dozens of times.

The preflight matters because the importer has no dry run. Once you press enter, records are being created on a live public repository. So the last honest checkpoint is local. Mine extracts the ZIP and checks that titles, dates, publishers, and descriptions are strings and non-empty, that publication dates are valid EDTF, that a resource type is present, that creators exist and have names, that ORCID and ISNI formats are right, that subject ids are well-formed authority URIs, and that every file entry names a file actually inside the ZIP. None of it is clever. All of it caught something.

The DOI sync is the other half. For each record in the log it reads the returned DOI and the import-recid, finds the one Markdown file carrying that id, and writes the DOI into front matter. It refuses to run unless the log is a complete success with the expected record count. It refuses to write when an id matches zero files or more than one. It refuses to overwrite a different existing DOI. It has stopped on me, and it was right every time.

On speed. The point of the script is not that it is faster to type. It is that the expensive part of this work is your attention, and a script spends yours only where it is needed. I still approve every collection creation and every import by hand, because those are the irreversible steps. Everything around them, the validation and the image generation and the reconciliation and the DOI mapping, runs the same way every time and never gets tired at issue fifty-four.

Import in small batches. I capped mine at four records. Reconcile against the live collection before each batch, import only what is missing, query again afterward, and stop when a batch makes no progress. That combination, plus stable ids, is what let me stop and restart this migration across several days without ever wondering what state it was in.

Sixty-seven issues, 829 articles, and the script never once lost its place. I did, several times. That is the division of labor I would recommend.

Tags : kcworks knowledge-commons invenio-rdm buildawesome 11ty open-access digital-humanities scholarly-publishing

Webmentions

No webmentions yet.

Previous

Five Lessons Indieweb Developers Can Learn from Ken Hawkins

I tried browser-side semantic search on thewhitestonefoundation.org after reading Ken Hawkins, measured it against my actual readers, and replaced it with Pagefind. The idea was still right.

Next

A Cable of Many Fibers: An Overview of Pragmatism for Graduate Students in the Humanities and Social Sciences

An accessible graduate-level overview of pragmatism's major thinkers, contested lineages, theories of truth and inquiry, and uses for humanities and social-science research.