oaktree-sapling 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +28 -0
- package/README.md +66 -0
- package/ci/run.sh +64 -0
- package/dist/cli.cjs +1230 -0
- package/package.json +50 -0
- package/paper-base.yml +29 -0
- package/plugins/gallery.mjs +224 -0
- package/templates/instance/brand/brand.yml +15 -0
- package/templates/instance/brand/logo.svg +4 -0
- package/templates/instance/editions/edition.yml +14 -0
- package/templates/instance/journal.yml +58 -0
- package/templates/instance/registry/papers.yml +20 -0
- package/templates/paper/.github/actions/engine/action.yml +59 -0
- package/templates/paper/.github/actions/engine/pins.yml +16 -0
- package/templates/paper/.github/workflows/check-post.yml +79 -0
- package/templates/paper/.github/workflows/check.yml +72 -0
- package/templates/paper/.github/workflows/ci.yml +64 -0
- package/templates/paper/.github/workflows/prepare.yml +34 -0
- package/templates/paper/.github/workflows/preview-deploy.yml +52 -0
- package/templates/paper/.github/workflows/publish.yml +33 -0
- package/templates/paper/.github/workflows/version-bump.yml +29 -0
- package/templates/paper/CODEOWNERS +9 -0
- package/templates/paper/bib.bib +6 -0
- package/templates/paper/gitignore +8 -0
- package/templates/paper/index.md +12 -0
- package/templates/paper/myst.yml +37 -0
- package/templates/site/.github/workflows/site.yml +73 -0
- package/templates/site/gitignore +5 -0
- package/templates/site/myst.yml +32 -0
- package/templates/site/package.json +9 -0
- package/templates/site/pages/index.md +25 -0
- package/templates/typst/LICENSE +21 -0
- package/templates/typst/frontmatter.typ +244 -0
- package/templates/typst/lapreprint.typ +310 -0
- package/templates/typst/template.typ +112 -0
- package/templates/typst/template.yml +82 -0
- package/typst.version +1 -0
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "oaktree-sapling",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "White-label journal engine: MyST + GitHub + Zenodo. All platform logic, referenced by version.",
|
|
5
|
+
"author": "paolo marzolo",
|
|
6
|
+
"license": "BSD-3-Clause",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/pollomarzo/whitelabel.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"oak": "dist/cli.cjs"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "npm run bundle && vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"typecheck": "tsc --noEmit",
|
|
19
|
+
"bundle": "esbuild src/cli.ts --bundle --platform=node --format=cjs --minify-whitespace --outfile=dist/cli.cjs",
|
|
20
|
+
"build:fixture": "node scripts/build-fixture.mjs",
|
|
21
|
+
"docs": "cd docs && npx -y mystmd@1.10.1 start",
|
|
22
|
+
"docs:build": "cd docs && npx -y mystmd@1.10.1 build --html --strict",
|
|
23
|
+
"prepack": "npm run typecheck && npm run bundle"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist/cli.cjs",
|
|
27
|
+
"templates/",
|
|
28
|
+
"!templates/*/README.md",
|
|
29
|
+
"ci/",
|
|
30
|
+
"plugins/",
|
|
31
|
+
"paper-base.yml",
|
|
32
|
+
"typst.version"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@curvenote/check-definitions": "^0.16.5",
|
|
36
|
+
"@curvenote/check-implementations": "^0.16.5",
|
|
37
|
+
"adm-zip": "^0.5.18",
|
|
38
|
+
"myst-cli": "^1.10.1",
|
|
39
|
+
"yaml": "^2.6.0",
|
|
40
|
+
"zod": "^4.0.0"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/adm-zip": "^0.5.8",
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"esbuild": "^0.28.1",
|
|
46
|
+
"js-yaml": "^4.1.0",
|
|
47
|
+
"typescript": "^5.6.0",
|
|
48
|
+
"vitest": "^4.1.10"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/paper-base.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
# Engine-owned base config every paper extends (design §5). Carries ZERO
|
|
4
|
+
# journal-specific bytes — brand, venue, funding, license live in INSTANCE-CONFIG
|
|
5
|
+
# editions/brand.
|
|
6
|
+
#
|
|
7
|
+
# The COMPLETE typst export lives here, not split across paper-base + edition ([R52]):
|
|
8
|
+
# myst merges `exports` by `id`, whole-entry, base-wins with NO field merge, so a
|
|
9
|
+
# skeleton here + `articles:` in the edition would race and drop one set of fields. So
|
|
10
|
+
# the canonical single-article export (index.md, the n=1 default) is declared whole here;
|
|
11
|
+
# editions declare NO exports. It carries no `template:` — compose() injects the
|
|
12
|
+
# engine-pinned zip URL (finding 2 / [R5]) so the template rides the engine tag. A
|
|
13
|
+
# multi-article paper overrides this whole entry in its own myst.yml (own wins by id).
|
|
14
|
+
project:
|
|
15
|
+
# Fixed thumbnail location, consumed by the gallery plugin.
|
|
16
|
+
thumbnail: thumbnails/thumbnail.png
|
|
17
|
+
exports:
|
|
18
|
+
- format: typst
|
|
19
|
+
id: typst-pdf
|
|
20
|
+
articles:
|
|
21
|
+
- file: index.md
|
|
22
|
+
level: 0
|
|
23
|
+
downloads:
|
|
24
|
+
- id: typst-pdf
|
|
25
|
+
title: PDF export
|
|
26
|
+
|
|
27
|
+
site:
|
|
28
|
+
options:
|
|
29
|
+
hide_toc: true
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gallery.mjs — the journal site's `paper-cards` directive (design §5; [S2]).
|
|
3
|
+
*
|
|
4
|
+
* A port of `impact-scholars.github.io/plugins/paper-gallery.mjs`, re-pointed from
|
|
5
|
+
* `papers.txt` + a hardcoded org constant to the **registry** (`registry/papers.yml`,
|
|
6
|
+
* design §9). Every URL is derived from a registry entry's `location` — never from an org
|
|
7
|
+
* constant — so the same plugin serves any tenant.
|
|
8
|
+
*
|
|
9
|
+
* This file is ENGINE-owned but is NOT engine TypeScript: myst consumes it at runtime by
|
|
10
|
+
* tag-pinned raw URL (`project.plugins:` accepts remote `.mjs`, myst-cli `config.ts:415-419`),
|
|
11
|
+
* so it never enters `dist/cli.cjs` and `myst.ts` stays the only importer of myst-cli.
|
|
12
|
+
*
|
|
13
|
+
* WHY IT MUST STAY DEPENDENCY-LIGHT: myst downloads a remote plugin into
|
|
14
|
+
* `<project>/_build/cache/config-item-<hash>.mjs` and imports it from there, so every bare
|
|
15
|
+
* import must resolve from the SITE repo's own `node_modules` — the mystmd install (an npx
|
|
16
|
+
* cache) is not on that resolution path. `js-yaml` is therefore declared in the site
|
|
17
|
+
* scaffold's `package.json` and installed by the site workflow. Add nothing else here
|
|
18
|
+
* without adding it there too.
|
|
19
|
+
*
|
|
20
|
+
* FAILURE IS HARD, ON PURPOSE. A registry entry whose `myst.yml` will not fetch is a broken
|
|
21
|
+
* registry: it must be fixed, not papered over. A failed build is not an outage — Pages keeps
|
|
22
|
+
* serving the last successful deploy, so the live journal stays exactly as it was until the
|
|
23
|
+
* entry is corrected. Degrading would publish a visibly broken card to readers and bury the
|
|
24
|
+
* signal in a log nobody reads. Note the division of labour, CORRECTED by a live run ([R80]):
|
|
25
|
+
* the `throw` below covers PER-PAPER failures (it propagates and crashes the build regardless
|
|
26
|
+
* of flags); `--strict` covers errors raised while building a page (a bad DOI, a missing
|
|
27
|
+
* image); and NEITHER covers "the plugin never loaded at all" — myst logs `Unknown plugin` +
|
|
28
|
+
* `unknown directive` and still exits 0, deploying a gallery-less page over a good one. That
|
|
29
|
+
* third case is caught in the site workflow by asserting this plugin's `name` appears in the
|
|
30
|
+
* build log, which is why the name below is load-bearing: do not rename it casually.
|
|
31
|
+
*
|
|
32
|
+
* The THUMBNAIL is deliberately not fetched here. This transform runs at `stage: 'document'`
|
|
33
|
+
* (`process/mdast.ts:224`), i.e. BEFORE `transformImagesToDisk` (`:438`), so the remote URL
|
|
34
|
+
* emitted below is picked up by `saveImageInStaticFolder` → `downloadAndSaveImage`
|
|
35
|
+
* (`transforms/images.ts:115-117`) and written into the site's public folder under a content
|
|
36
|
+
* hash. Three consequences: the published site serves a LOCAL copy rather than hotlinking
|
|
37
|
+
* `raw.githubusercontent.com`; a broken thumbnail is already an error-kind warning
|
|
38
|
+
* (`RuleId.imageDownloads`, `images.ts:82-88`, including the HTML-error-page content-type
|
|
39
|
+
* case), so `--strict` fails the build on it with no extra check here; and caching titles in
|
|
40
|
+
* the registry could never make the site build hermetic, because N thumbnail downloads would
|
|
41
|
+
* remain either way.
|
|
42
|
+
*/
|
|
43
|
+
import { readFileSync } from 'node:fs';
|
|
44
|
+
import yaml from 'js-yaml';
|
|
45
|
+
|
|
46
|
+
/** The registry, relative to the site build root. Fixed, not an option: [S8] fixed the repo
|
|
47
|
+
* layout (the site IS the instance-config repo), so a `:registry:` option would be
|
|
48
|
+
* configurability for a layout that cannot occur. Adding one later stays compatible. */
|
|
49
|
+
export const REGISTRY_PATH = 'registry/papers.yml';
|
|
50
|
+
|
|
51
|
+
/** Read + parse the registry. A missing/malformed registry is fatal: without it the page
|
|
52
|
+
* would deploy with an empty gallery, which reads as "this journal has no papers". */
|
|
53
|
+
export function loadRegistry(file = REGISTRY_PATH) {
|
|
54
|
+
let raw;
|
|
55
|
+
try {
|
|
56
|
+
raw = readFileSync(file, 'utf8');
|
|
57
|
+
} catch (err) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`paper-cards: cannot read the paper registry at "${file}" (${err.message}). ` +
|
|
60
|
+
`The gallery is built from this repo's registry — run the build from the repo root.`,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const entries = yaml.load(raw) ?? [];
|
|
64
|
+
if (!Array.isArray(entries)) {
|
|
65
|
+
throw new Error(`paper-cards: "${file}" must be a LIST of registry entries (design §9).`);
|
|
66
|
+
}
|
|
67
|
+
return entries;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The papers a `:::{paper-cards}` block shows, in **registry file order** — the editor
|
|
72
|
+
* controls sequence by where they insert the entry, which is the one ordering rule that
|
|
73
|
+
* needs no extra field. `edition` omitted → every registered paper (what the scaffold's
|
|
74
|
+
* single page uses); a fresh journal has exactly one edition, and a tenant who grows a
|
|
75
|
+
* second one adds a page and filters it ([S6]).
|
|
76
|
+
*/
|
|
77
|
+
export function selectEntries(registry, opts = {}) {
|
|
78
|
+
const { edition } = opts;
|
|
79
|
+
return edition ? registry.filter((entry) => entry.edition === edition) : [...registry];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** `location.path` as a URL prefix: '.' / '' → '', 'papers/x' → 'papers/x/'. Kept even
|
|
83
|
+
* though every entry is `.` today — it is what keeps the repo=journal (n>1) tier reachable
|
|
84
|
+
* without touching this plugin ([S7]). */
|
|
85
|
+
function pathPrefix(path) {
|
|
86
|
+
const trimmed = (path ?? '.').replace(/^\.$/, '').replace(/^\/+|\/+$/g, '');
|
|
87
|
+
return trimmed ? `${trimmed}/` : '';
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The three URLs a card is built from. The ONE place [S4]'s shape shows up, and the one
|
|
92
|
+
* place that changes if we ever read a built `myst.json` instead of the raw `myst.yml`.
|
|
93
|
+
*
|
|
94
|
+
* `site_url` is honored when present (custom domains, non-Pages hosting) and otherwise
|
|
95
|
+
* derived from `location.repo`. Raw URLs go through **`HEAD`**, not `main`, so a tenant
|
|
96
|
+
* whose default branch is named otherwise still resolves. The thumbnail path is not a
|
|
97
|
+
* guess: `paper-base.yml` pins `project.thumbnail: thumbnails/thumbnail.png` for every paper.
|
|
98
|
+
*/
|
|
99
|
+
export function paperUrls(entry) {
|
|
100
|
+
const repo = entry?.location?.repo;
|
|
101
|
+
if (typeof repo !== 'string' || !repo.includes('/')) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`paper-cards: registry entry "${entry?.slug ?? entry?.id ?? '?'}" has no valid ` +
|
|
104
|
+
`location.repo ("owner/name"). Fix the entry in ${REGISTRY_PATH}.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const [owner, name] = repo.split('/');
|
|
108
|
+
const prefix = pathPrefix(entry.location.path);
|
|
109
|
+
const raw = `https://raw.githubusercontent.com/${repo}/HEAD/${prefix}`;
|
|
110
|
+
return {
|
|
111
|
+
siteUrl: entry.site_url ?? `https://${owner}.github.io/${name}`,
|
|
112
|
+
configUrl: `${raw}myst.yml`,
|
|
113
|
+
thumbUrl: `${raw}thumbnails/thumbnail.png`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One card node, PURE: (registry entry, that paper's fetched myst config) → mdast. Title and
|
|
119
|
+
* keywords come from the paper (the registry stays a thin pointer list, [S4]); the DOI comes
|
|
120
|
+
* from the registry, since that is the one display field the registry actually owns.
|
|
121
|
+
*/
|
|
122
|
+
export function cardFrom(entry, config) {
|
|
123
|
+
const { siteUrl, thumbUrl } = paperUrls(entry);
|
|
124
|
+
const project = config?.project ?? {};
|
|
125
|
+
const title = project.title || entry.slug || entry.id;
|
|
126
|
+
const keywords = project.keywords ?? [];
|
|
127
|
+
|
|
128
|
+
const children = [
|
|
129
|
+
{ type: 'header', children: [{ type: 'text', value: title }] },
|
|
130
|
+
{ type: 'image', url: thumbUrl, alt: title, width: '100%' },
|
|
131
|
+
];
|
|
132
|
+
if (keywords.length > 0) {
|
|
133
|
+
children.push({ type: 'paragraph', children: [{ type: 'text', value: keywords.join(' | ') }] });
|
|
134
|
+
}
|
|
135
|
+
// The DOI is TEXT, deliberately not a link. myst converts any `link` whose url is a DOI
|
|
136
|
+
// into a `cite` node (`myst-cli/transforms/dois.ts:239-242`, no per-node opt-out), which
|
|
137
|
+
// on a gallery card is wrong twice over: the card would render a citation label plus a
|
|
138
|
+
// stray bibliography instead of the identifier, and each card would cost a doi.org
|
|
139
|
+
// metadata fetch per build — rate-limited upstream, and one unreachable DOI fails the
|
|
140
|
+
// whole journal under `--strict`. Found on the first live run, with a sandbox DOI.
|
|
141
|
+
// The card itself already links to the paper, whose own page carries a real DOI link.
|
|
142
|
+
if (entry.doi) {
|
|
143
|
+
children.push({
|
|
144
|
+
type: 'footer',
|
|
145
|
+
children: [
|
|
146
|
+
{ type: 'paragraph', children: [{ type: 'text', value: `DOI: ${entry.doi}` }] },
|
|
147
|
+
],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return { type: 'card', url: siteUrl, children };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Fetch one paper's myst config. Throws naming BOTH the slug and the URL, because the fix
|
|
154
|
+
* is a registry edit and the editor needs to know which line to edit. */
|
|
155
|
+
export async function fetchPaperConfig(entry, fetchImpl = fetch) {
|
|
156
|
+
const { configUrl } = paperUrls(entry);
|
|
157
|
+
let response;
|
|
158
|
+
try {
|
|
159
|
+
response = await fetchImpl(configUrl);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`paper-cards: failed to fetch the config for "${entry.slug ?? entry.id}" from ${configUrl} ` +
|
|
163
|
+
`(${err.message}). Fix or remove the entry in ${REGISTRY_PATH}.`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
if (!response.ok) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`paper-cards: failed to fetch the config for "${entry.slug ?? entry.id}" from ${configUrl} ` +
|
|
169
|
+
`(${response.status} ${response.statusText}). Fix or remove the entry in ${REGISTRY_PATH}.`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return yaml.load(await response.text());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const paperCardsDirective = {
|
|
176
|
+
name: 'paper-cards',
|
|
177
|
+
doc: 'A gallery of cards, one per registered paper.',
|
|
178
|
+
options: {
|
|
179
|
+
edition: {
|
|
180
|
+
type: String,
|
|
181
|
+
doc: 'Only show papers whose registry `edition` matches. Omit for every registered paper.',
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
run(data) {
|
|
185
|
+
const entries = selectEntries(loadRegistry(), { edition: data.options?.edition });
|
|
186
|
+
if (entries.length === 0) {
|
|
187
|
+
return [{ type: 'paragraph', children: [{ type: 'text', value: 'No papers found.' }] }];
|
|
188
|
+
}
|
|
189
|
+
// The directive is sync, so the per-paper fetch happens in the transform below; these
|
|
190
|
+
// placeholder nodes carry the entry across.
|
|
191
|
+
return [
|
|
192
|
+
{
|
|
193
|
+
type: 'grid',
|
|
194
|
+
columns: [1, 1, 2, 3],
|
|
195
|
+
children: entries.map((entry) => ({ type: 'paper-card-ref', entry, children: [] })),
|
|
196
|
+
},
|
|
197
|
+
];
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
function paperCardsTransform(opts, utils) {
|
|
202
|
+
return async (mdast) => {
|
|
203
|
+
const nodes = utils.selectAll('paper-card-ref', mdast);
|
|
204
|
+
if (nodes.length === 0) return;
|
|
205
|
+
await Promise.all(
|
|
206
|
+
nodes.map(async (node) => {
|
|
207
|
+
const { entry } = node;
|
|
208
|
+
const config = await fetchPaperConfig(entry);
|
|
209
|
+
const card = cardFrom(entry, config);
|
|
210
|
+
delete node.entry;
|
|
211
|
+
Object.assign(node, card);
|
|
212
|
+
}),
|
|
213
|
+
);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const plugin = {
|
|
218
|
+
name: 'Paper Gallery',
|
|
219
|
+
directives: [paperCardsDirective],
|
|
220
|
+
// `stage: 'document'` is load-bearing, not incidental — see the thumbnail note at the top.
|
|
221
|
+
transforms: [{ plugin: paperCardsTransform, stage: 'document' }],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
export default plugin;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
# Brand (design §5) — the journal's visual identity. HTML assets (site.options.logo /
|
|
4
|
+
# favicon) may be absolute URLs; the typst PDF watermark MUST be a REAL local file, which
|
|
5
|
+
# compose() absolutizes (`./logo.svg` → <instanceRoot>/brand/logo.svg). Replace logo.svg
|
|
6
|
+
# with your own mark.
|
|
7
|
+
site:
|
|
8
|
+
options:
|
|
9
|
+
logo_text: CHANGE-ME Journal
|
|
10
|
+
# logo: https://example.org/logo.svg
|
|
11
|
+
# favicon: https://example.org/logo.svg
|
|
12
|
+
# logo_url: https://example.org
|
|
13
|
+
project:
|
|
14
|
+
options:
|
|
15
|
+
logo: ./logo.svg
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" role="img" aria-label="Journal logo placeholder">
|
|
2
|
+
<rect width="120" height="120" rx="12" fill="#1a1a1a"/>
|
|
3
|
+
<text x="60" y="72" font-family="sans-serif" font-size="48" font-weight="700" fill="#ffffff" text-anchor="middle">J</text>
|
|
4
|
+
</svg>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
version: 1
|
|
2
|
+
|
|
3
|
+
# Edition data (design §5) — venue/license/funding. Declares NO `exports:` ([R52]): the
|
|
4
|
+
# complete typst export lives in the engine's paper-base.yml (myst merges exports by id
|
|
5
|
+
# whole-entry, so splitting fields here would race). Do NOT add `extends:` — compose()
|
|
6
|
+
# assembles the engine‹edition‹brand chain. `oak bootstrap` renames this file to
|
|
7
|
+
# `editions/<edition>.yml`.
|
|
8
|
+
project:
|
|
9
|
+
subject: Article
|
|
10
|
+
venue: CHANGE-ME Journal
|
|
11
|
+
license: CC-BY-4.0
|
|
12
|
+
open_access: true
|
|
13
|
+
# funding: |
|
|
14
|
+
# Your funder
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# INSTANCE-CONFIG manifest (design §3) — the tenant's journal identity. Data only, no
|
|
2
|
+
# engine logic. `oak bootstrap journal` stamps this; additive-only (dec. 24), so an older
|
|
3
|
+
# pinned engine ignores keys it doesn't know. MUST stay in a PUBLIC repo ([R32], dec. 16):
|
|
4
|
+
# Stage-1 fork-PR builds clone it with no token.
|
|
5
|
+
name: CHANGE-ME Journal
|
|
6
|
+
# url: https://example.org
|
|
7
|
+
|
|
8
|
+
tier: paper
|
|
9
|
+
|
|
10
|
+
# id policy (design dec. 20). `id_sentinel` is the one id `oak validate` rejects (the
|
|
11
|
+
# template placeholder). `id_pattern` is an anchored regex every real paper id must match —
|
|
12
|
+
# customize to your journal's convention.
|
|
13
|
+
id_sentinel: CHANGE-ME-template-placeholder
|
|
14
|
+
id_pattern: "^[a-z0-9]+-\\d{4}-[a-z0-9-]+$"
|
|
15
|
+
|
|
16
|
+
# Your journal's typst PDF template. Omit it to use the engine's default template (the
|
|
17
|
+
# usual case — the engine template is already brandable by parameter: watermark, colors).
|
|
18
|
+
#
|
|
19
|
+
# Accepts myst's three template forms, and the difference between two of them is the one
|
|
20
|
+
# thing worth reading twice:
|
|
21
|
+
#
|
|
22
|
+
# ./typst-template a PATH, relative to THIS directory (instance-config root)
|
|
23
|
+
# ../shared/typst a PATH, relative to this directory
|
|
24
|
+
# /srv/typst-template a PATH, absolute
|
|
25
|
+
# https://…/x.zip a URL (prefer a tag/release URL; a branch URL floats and warns)
|
|
26
|
+
# lapreprint-typst a NAME, looked up against myst's template registry
|
|
27
|
+
#
|
|
28
|
+
# ONLY `./` and `../` mean "a directory in my instance-config". A bare `typst-template`
|
|
29
|
+
# with no `./` is a template NAME, even if a directory of that name sits right next to
|
|
30
|
+
# this file — one string always means one thing, whatever happens to be on disk. If you
|
|
31
|
+
# meant the directory, write `./typst-template`. (`oak validate` warns when a bare value
|
|
32
|
+
# shadows a real directory here, which is the one case you'd guess wrong.)
|
|
33
|
+
#
|
|
34
|
+
# A paper may override this with its own `exports[].template`; that is allowed and
|
|
35
|
+
# applied, and `oak validate` flags it on the PR so the divergence from journal identity
|
|
36
|
+
# is reviewed rather than silent.
|
|
37
|
+
# typst_template: ./typst-template
|
|
38
|
+
|
|
39
|
+
preview:
|
|
40
|
+
# 'artifact' degrades to a build-artifact link. Switch to 'cloudflare' + set
|
|
41
|
+
# cf_project_name once the paper repos carry the CLOUDFLARE_* secrets ([R6]).
|
|
42
|
+
provider: artifact
|
|
43
|
+
branch_pattern: paper-{repo}-{pr}
|
|
44
|
+
|
|
45
|
+
# Optional per-tenant Zenodo config ([R19]) — a fresh journal has neither.
|
|
46
|
+
zenodo: {}
|
|
47
|
+
# community: your-zenodo-community
|
|
48
|
+
# description_blurb: >-
|
|
49
|
+
# Created as part of the CHANGE-ME Journal.
|
|
50
|
+
|
|
51
|
+
# Journal-controlled editorial checks (slice 4 Layer B). The author cannot weaken this set —
|
|
52
|
+
# it lives here in instance-config; `oak validate` runs it and the PR check gates on it.
|
|
53
|
+
checks:
|
|
54
|
+
- id: authors-exist
|
|
55
|
+
- id: authors-have-orcid
|
|
56
|
+
- id: authors-have-credit-roles
|
|
57
|
+
- id: abstract-exists
|
|
58
|
+
- id: keywords-defined
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Paper registry (design §9). One entry per paper: {id, slug, location, edition} ([R47]).
|
|
2
|
+
# `id` is the deposit/dedup key (myst-native project.id); `slug` is the URL/thumbnail path;
|
|
3
|
+
# `location` is {repo, path} where the paper lives. Additive-only (dec. 24). Empty until the
|
|
4
|
+
# first paper is registered.
|
|
5
|
+
#
|
|
6
|
+
# Registration is a MANUAL editorial PR ([S5]) — and since the journal site lives in this
|
|
7
|
+
# same repo, that PR is also what redeploys the gallery. The registry stays a thin POINTER
|
|
8
|
+
# list: the gallery fetches each paper's title, keywords and thumbnail from the paper repo
|
|
9
|
+
# itself, so nothing here needs updating when a paper's title changes.
|
|
10
|
+
#
|
|
11
|
+
# - id: j-2026-example
|
|
12
|
+
# slug: example
|
|
13
|
+
# location: { repo: owner/example-paper, path: . }
|
|
14
|
+
# edition: edition
|
|
15
|
+
# doi: 10.5281/zenodo.1234567 # optional; shown on the card once deposited
|
|
16
|
+
# site_url: https://journal.example.org/example
|
|
17
|
+
# # optional. Where the paper is PUBLISHED, when it isn't where we'd guess.
|
|
18
|
+
# # Omitted → derived as https://<owner>.github.io/<repo-name> from
|
|
19
|
+
# # location.repo. Set it for a custom domain or non-Pages hosting.
|
|
20
|
+
[]
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
name: engine
|
|
2
|
+
description: >-
|
|
3
|
+
Resolve the engine ref from myst.yml, check out the pinned ENGINE repo at that ref,
|
|
4
|
+
and dispatch a verb to it. The ONE place the pins live (design §6a, §1a). Frozen +
|
|
5
|
+
CODEOWNERS-gated; all *logic* lives behind the engine version, improvable by a bump.
|
|
6
|
+
|
|
7
|
+
inputs:
|
|
8
|
+
args:
|
|
9
|
+
description: "verb + args for the engine (e.g. 'build', 'release --tag v1.0.0')"
|
|
10
|
+
required: true
|
|
11
|
+
|
|
12
|
+
runs:
|
|
13
|
+
using: composite
|
|
14
|
+
steps:
|
|
15
|
+
# --- resolve the engine ref from the paper's own myst.yml (raw, pre-extends) ------
|
|
16
|
+
# Quoted key form ["oaktree-sapling"] — an unquoted hyphenated key is yq-version
|
|
17
|
+
# sensitive ([R14]); explicit guard so a missing options block fails LOUDLY, not as a
|
|
18
|
+
# baffling downstream checkout error.
|
|
19
|
+
- id: ref
|
|
20
|
+
shell: bash
|
|
21
|
+
run: |
|
|
22
|
+
ref=$(yq '.project.options["oaktree-sapling"].version // ""' myst.yml)
|
|
23
|
+
if [ -z "$ref" ] || [ "$ref" = "null" ]; then
|
|
24
|
+
echo "::error::project.options.oaktree-sapling.version missing from myst.yml"
|
|
25
|
+
exit 1
|
|
26
|
+
fi
|
|
27
|
+
echo "ref=$ref" >> "$GITHUB_OUTPUT"
|
|
28
|
+
|
|
29
|
+
# --- pins.yml = the single source of truth, read by CI *and* local oak (dec. 21) --
|
|
30
|
+
- id: pins
|
|
31
|
+
shell: bash
|
|
32
|
+
run: |
|
|
33
|
+
pins=.github/actions/engine/pins.yml
|
|
34
|
+
echo "engine=$(yq '.engine_repo' "$pins")" >> "$GITHUB_OUTPUT"
|
|
35
|
+
echo "instance=$(yq '.instance_repo // "."' "$pins")" >> "$GITHUB_OUTPUT"
|
|
36
|
+
|
|
37
|
+
# --- check out ENGINE@ref. Pinned `repository:` ⇒ the ref provably resolves inside
|
|
38
|
+
# the engine repo; only the ref floats (design §6a, [R9]). Ref-class trust (raw SHA /
|
|
39
|
+
# PR-merge refs from forks) is enforced by `oak validate` inside the engine ([R41]).
|
|
40
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
41
|
+
with:
|
|
42
|
+
repository: ${{ steps.pins.outputs.engine }}
|
|
43
|
+
ref: ${{ steps.ref.outputs.ref }}
|
|
44
|
+
path: .engine
|
|
45
|
+
|
|
46
|
+
# --- OPT-IN per-paper execution env only (design §0/[R44]): micromamba iff the paper
|
|
47
|
+
# ships paper-environment.yml. No file → no Python anywhere.
|
|
48
|
+
- uses: mamba-org/setup-micromamba@ce51e99f4bb8a82ab7158c4dc59ef4634c59c4f9 # v3.1.0
|
|
49
|
+
if: ${{ hashFiles('paper-environment.yml') != '' }}
|
|
50
|
+
with:
|
|
51
|
+
environment-file: paper-environment.yml
|
|
52
|
+
cache-environment: true
|
|
53
|
+
|
|
54
|
+
# --- dispatch. INSTANCE_REPO comes from pins.yml ('.' when co-located, repo=journal).
|
|
55
|
+
# Debuggability: run.sh echoes what it resolved at the top of every run.
|
|
56
|
+
- shell: bash
|
|
57
|
+
env:
|
|
58
|
+
INSTANCE_REPO: ${{ steps.pins.outputs.instance }}
|
|
59
|
+
run: .engine/ci/run.sh ${{ inputs.args }}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Pins — the trust boundary (design §6a, dec. 21, [R37]). The engine repo (org/repo) is
|
|
2
|
+
# pinned here; only the *ref* (project.options.oaktree-sapling.version in myst.yml) floats.
|
|
3
|
+
# Read by BOTH the CI shim (yq, in action.yml) and local `oak`, so CI and local share one
|
|
4
|
+
# source of truth and can't drift. Covered by the broad `.github/` CODEOWNERS gate — no
|
|
5
|
+
# new gated path. `oak bootstrap` / copier fills these; editing them is CODEOWNERS-gated
|
|
6
|
+
# because an instance/edition config can name MyST plugins (JS run at build time), so the
|
|
7
|
+
# instance repo is a code-adjacent source (same trust argument as the engine source).
|
|
8
|
+
|
|
9
|
+
# INTERIM home: the engine is developed on a personal account first; it moves to
|
|
10
|
+
# open-scholar-nexus/oaktree-sapling later via replayed commits ([R56]). Because this is
|
|
11
|
+
# the only place the home is named, the move is a one-line owner swap here.
|
|
12
|
+
engine_repo: pollomarzo/whitelabel
|
|
13
|
+
|
|
14
|
+
# owner/repo of INSTANCE-CONFIG. Set to "." (or omit) when instance-config is co-located
|
|
15
|
+
# in this repo (repo=journal). copier replaces the placeholder below at bootstrap.
|
|
16
|
+
instance_repo: pollomarzo/CHANGE-ME-instance-config
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
name: Journal checks post
|
|
2
|
+
|
|
3
|
+
# Stage 2 of the two-stage journal-checks flow (slice 4b). POST only, trusted: runs in
|
|
4
|
+
# BASE-repo context after "Journal checks" (check.yml) completes, so it holds the write token
|
|
5
|
+
# that fork PRs never get — the same split preview-deploy.yml uses. It ONLY downloads Stage
|
|
6
|
+
# 1's precomputed report artifact and posts it; it NEVER re-runs validate or rebuilds paper
|
|
7
|
+
# content (that would reopen the injection hole through the shim's back door). The engine ref
|
|
8
|
+
# + action code come from the BASE default branch (guaranteed by workflow_run).
|
|
9
|
+
#
|
|
10
|
+
# Posts BOTH a first-class "Journal checks" Check Run on the PR HEAD (gates merge + annotates)
|
|
11
|
+
# and an always-on sticky PR comment (visibility — authors rarely click the Check Run
|
|
12
|
+
# "Details"). On a `push` to main it posts the success Check Run with no comment (no PR).
|
|
13
|
+
|
|
14
|
+
on:
|
|
15
|
+
workflow_run:
|
|
16
|
+
workflows: ["Journal checks"]
|
|
17
|
+
types: [completed]
|
|
18
|
+
|
|
19
|
+
permissions:
|
|
20
|
+
contents: read # checkout the base default branch (engine ref + action code)
|
|
21
|
+
actions: read # download the cross-run artifact
|
|
22
|
+
checks: write # post the "Journal checks" Check Run
|
|
23
|
+
pull-requests: write # upsert the sticky checks comment
|
|
24
|
+
|
|
25
|
+
concurrency:
|
|
26
|
+
# Key on head repo + branch so two forks PRing from `main` don't cancel each other ([R15]).
|
|
27
|
+
group: check-post-${{ github.event.workflow_run.head_repository.full_name }}-${{ github.event.workflow_run.head_branch }}
|
|
28
|
+
cancel-in-progress: true
|
|
29
|
+
|
|
30
|
+
jobs:
|
|
31
|
+
post:
|
|
32
|
+
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
|
33
|
+
runs-on: ubuntu-latest
|
|
34
|
+
steps:
|
|
35
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 — BASE default branch ⇒ engine ref AND action code from base
|
|
36
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
37
|
+
with:
|
|
38
|
+
name: journal-checks
|
|
39
|
+
path: journal-checks
|
|
40
|
+
run-id: ${{ github.event.workflow_run.id }}
|
|
41
|
+
github-token: ${{ github.token }}
|
|
42
|
+
|
|
43
|
+
# The artifact comes from the untrusted Stage-1 job; head-sha/pr-number flow from here into
|
|
44
|
+
# the `args:` string spliced into a shell in ./.github/actions/engine. Validate shape and
|
|
45
|
+
# fail CLOSED before they leave this step — a git sha is [0-9a-f]{7,40}, a PR number is
|
|
46
|
+
# digits — so no newline (GITHUB_OUTPUT injection) or shell metacharacter can ride through
|
|
47
|
+
# even if Stage 1 gets attacker bytes into the files.
|
|
48
|
+
- id: meta
|
|
49
|
+
run: |
|
|
50
|
+
sha=$(cat journal-checks/head-sha)
|
|
51
|
+
if ! printf '%s' "$sha" | grep -qE '^[0-9a-f]{7,40}$'; then
|
|
52
|
+
echo "::error::head-sha in artifact is not a valid git sha; refusing to post"
|
|
53
|
+
exit 1
|
|
54
|
+
fi
|
|
55
|
+
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
|
56
|
+
if [ -f journal-checks/pr-number ]; then
|
|
57
|
+
pr=$(cat journal-checks/pr-number)
|
|
58
|
+
if ! printf '%s' "$pr" | grep -qE '^[0-9]{1,10}$'; then
|
|
59
|
+
echo "::error::pr-number in artifact is not a valid number; refusing to post"
|
|
60
|
+
exit 1
|
|
61
|
+
fi
|
|
62
|
+
echo "pr=$pr" >> "$GITHUB_OUTPUT"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# Posts the precomputed report — the Check Run on the PR HEAD sha and, when a PR, the
|
|
66
|
+
# sticky comment. Best-effort inside the engine: a post failure degrades to a
|
|
67
|
+
# `::warning::`, never fails this job.
|
|
68
|
+
- uses: ./.github/actions/engine
|
|
69
|
+
with:
|
|
70
|
+
args: >-
|
|
71
|
+
check-post
|
|
72
|
+
--report journal-checks/report.json
|
|
73
|
+
--repo ${{ github.repository }}
|
|
74
|
+
--sha ${{ steps.meta.outputs.sha }}
|
|
75
|
+
--base ${{ github.event.repository.default_branch }}
|
|
76
|
+
--verified-head ${{ github.event.workflow_run.head_sha }}
|
|
77
|
+
${{ steps.meta.outputs.pr && format('--pr {0}', steps.meta.outputs.pr) || '' }}
|
|
78
|
+
env:
|
|
79
|
+
GH_TOKEN: ${{ github.token }}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
name: Journal checks
|
|
2
|
+
|
|
3
|
+
# Stage 1 of the two-stage journal-checks flow (slice 4b). COMPUTE only, untrusted: runs on
|
|
4
|
+
# the `pull_request` event with a read-only, secret-less token — safe for fork content, which
|
|
5
|
+
# `oak validate` only READS. It writes the check report to an artifact and NEVER posts to the
|
|
6
|
+
# PR. All PR write-back (the Check Run + the sticky comment) happens in Stage 2
|
|
7
|
+
# (check-post.yml, a `workflow_run` job in base context), exactly like the preview split
|
|
8
|
+
# (ci.yml → preview-deploy.yml): fork + same-repo PRs take the identical path, and the step
|
|
9
|
+
# that runs untrusted paper content never holds a writable token.
|
|
10
|
+
#
|
|
11
|
+
# This job SUCCEEDS whenever a valid report was produced — even when checks FAIL — so Stage 2
|
|
12
|
+
# always fires and posts the (failing) Check Run that gates the merge. Only a genuine engine
|
|
13
|
+
# crash (no report / invalid JSON) fails this job.
|
|
14
|
+
|
|
15
|
+
on:
|
|
16
|
+
pull_request:
|
|
17
|
+
push:
|
|
18
|
+
branches: [main]
|
|
19
|
+
workflow_dispatch:
|
|
20
|
+
|
|
21
|
+
permissions:
|
|
22
|
+
contents: read # checkout only; the untrusted compute job holds nothing else
|
|
23
|
+
|
|
24
|
+
concurrency:
|
|
25
|
+
group: ${{ github.event_name == 'pull_request' && format('check-{0}', github.head_ref) || 'check-main' }}
|
|
26
|
+
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
|
27
|
+
|
|
28
|
+
jobs:
|
|
29
|
+
validate:
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
33
|
+
|
|
34
|
+
# `oak validate` exits 1 on a blocking failure (its contract, used by the merge gate +
|
|
35
|
+
# local runs). Here that must NOT fail Stage 1 — we only need the report file. So the
|
|
36
|
+
# step tolerates a nonzero exit; the guard below re-imposes "fail on a genuine crash".
|
|
37
|
+
- id: validate
|
|
38
|
+
continue-on-error: true
|
|
39
|
+
uses: ./.github/actions/engine
|
|
40
|
+
with:
|
|
41
|
+
args: validate --report report.json
|
|
42
|
+
|
|
43
|
+
# Last resort only: `oak validate` writes a failing report even for a usage error or an
|
|
44
|
+
# unexpected throw, so reaching this branch means the engine died before it could write
|
|
45
|
+
# anything at all. Point at the step that holds the real message rather than restating
|
|
46
|
+
# "crash" — a bare "no valid report" told the author nothing about what to fix.
|
|
47
|
+
- name: Ensure a valid report was produced
|
|
48
|
+
run: |
|
|
49
|
+
if ! jq -e '.checkRun.conclusion' report.json >/dev/null 2>&1; then
|
|
50
|
+
echo "::error::oak validate wrote no report at all — the engine died before it could report. The real error is in the '${{ steps.validate.outcome }}' validate step's log above; nothing was posted to the PR."
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
echo "report ok — conclusion: $(jq -r '.checkRun.conclusion' report.json)"
|
|
54
|
+
|
|
55
|
+
# Stash the PR HEAD sha (GITHUB_SHA is the ephemeral merge commit on a PR — a Check Run
|
|
56
|
+
# there never surfaces on the PR) and the PR number (workflow_run.pull_requests is EMPTY
|
|
57
|
+
# for forks). Stage 2 reads both from the artifact.
|
|
58
|
+
- name: Record head sha + PR number
|
|
59
|
+
run: |
|
|
60
|
+
echo "${{ github.event.pull_request.head.sha || github.sha }}" > head-sha
|
|
61
|
+
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
62
|
+
echo "${{ github.event.pull_request.number }}" > pr-number
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
|
66
|
+
with:
|
|
67
|
+
name: journal-checks
|
|
68
|
+
path: |
|
|
69
|
+
report.json
|
|
70
|
+
head-sha
|
|
71
|
+
pr-number
|
|
72
|
+
if-no-files-found: error
|