uniweb 0.78.0 → 0.79.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/package.json +5 -5
- package/partials/agents.md +5 -1
- package/src/backend/asset-download.js +49 -0
- package/src/backend/site-media.js +13 -5
- package/src/backend/site-sync.js +31 -3
- package/src/commands/publish.js +2 -1
- package/src/commands/pull.js +20 -1
- package/src/commands/push.js +2 -1
- package/src/framework-index.json +4 -4
- package/templates/site/_gitignore +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "uniweb",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.79.1",
|
|
4
4
|
"description": "Create structured Vite + React sites with content/code separation",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -41,17 +41,17 @@
|
|
|
41
41
|
"js-yaml": "^4.1.0",
|
|
42
42
|
"prompts": "^2.4.2",
|
|
43
43
|
"tar": "^7.0.0",
|
|
44
|
-
"@uniweb/runtime": "^0.28.3",
|
|
45
44
|
"@uniweb/kit": "^0.19.11",
|
|
45
|
+
"@uniweb/schemas": "^0.9.2",
|
|
46
46
|
"@uniweb/core": "^0.35.0",
|
|
47
|
+
"@uniweb/runtime": "^0.28.3",
|
|
47
48
|
"@uniweb/content-writer": "^0.3.4",
|
|
48
|
-
"@uniweb/semantic-parser": "^1.4.1"
|
|
49
|
-
"@uniweb/schemas": "^0.9.1"
|
|
49
|
+
"@uniweb/semantic-parser": "^1.4.1"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@uniweb/content-reader": "^1.2.7",
|
|
53
53
|
"@uniweb/semantic-parser": "^1.4.1",
|
|
54
|
-
"@uniweb/build": "^0.
|
|
54
|
+
"@uniweb/build": "^0.73.1"
|
|
55
55
|
},
|
|
56
56
|
"peerDependenciesMeta": {
|
|
57
57
|
"@uniweb/build": {
|
package/partials/agents.md
CHANGED
|
@@ -866,6 +866,10 @@ site/
|
|
|
866
866
|
|
|
867
867
|
Keep those folders flat: `records/article/design-tips.md` works; `records/article/2025/design-tips.md` is read as the `2025` schema of an `article` org, which is not what you meant. Organise in `records/folder.yml` instead. A file whose name starts with `_` is not a record — somewhere to keep work in progress.
|
|
868
868
|
|
|
869
|
+
**Links are records too — `@uniweb/link`.** A file in `records/uniweb/link/` holding `url:` and nothing else is a link record (one per file; no body, no other fields). Its display text and tags go on its `records/folder.yml` entry (`- path: uniweb/link/launch-video.yml` with `label:` / `tags:`), like any record's. Select links with a query `schema: '@uniweb/link'` and declare them in `meta.js` as `data: { videos: '@uniweb/link' }`; each arrives as `{ url, $name, $label, $tags }`. A link's `url` is absolute (`https://…`), and `uniweb push` refuses a relative one and a `draft: true` link.
|
|
870
|
+
|
|
871
|
+
**Files are records too — `@uniweb/file`.** The file itself is the record: any file dropped into `records/uniweb/file/` (a PDF, a spreadsheet, any type) is a record named by its file name without the extension; nothing in it is read. Its label and tags go on its `records/folder.yml` entry (`- path: uniweb/file/brochure.pdf`). Query with `schema: '@uniweb/file'`, declare with `data: { downloads: '@uniweb/file' }`; each arrives as `{ file: { url, name, mime, size }, $name, $label, $tags }` — link to `file.url`. `uniweb push` uploads the file with the site's media; a pull fetches it back. Two files of one name with different extensions clash — rename one. On a site that goes to a backend, a component must declare the type (`@uniweb/link` or `@uniweb/file`) for a query over it to be accepted.
|
|
872
|
+
|
|
869
873
|
**Four formats, one shape.** All of these produce the same records at runtime:
|
|
870
874
|
|
|
871
875
|
| Format | Best for | Notes |
|
|
@@ -907,7 +911,7 @@ The body — the value of the schema's content field, `body.content`.
|
|
|
907
911
|
- article/2025-*.md
|
|
908
912
|
```
|
|
909
913
|
|
|
910
|
-
A path under a folder is relative to `records/`, naming one file or matching many. A record's entry can instead be `{ path, tags, label }` — what the folder says about the record: a component receives them beside it as `$tags` (in the order written) and `$label`, and a query reads them like any key (`where: { $tags: featured }`); such an entry may sit at the top level, where a bare path is refused. A folder takes `tags:` too. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `folder.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
|
|
914
|
+
A path under a folder is relative to `records/`, naming one file or matching many. A record's entry can instead be `{ path, tags, label }` — what the folder says about the record: a component receives them beside it as `$tags` (in the order written) and `$label`, and a query reads them like any key (`where: { $tags: featured }`); such an entry may sit at the top level, where a bare path is refused. A `label` — a record entry's or a folder's — is one text in the site's language, or one per language (`label: { en: Launch video, fr: Vidéo de lancement }`); `$label` is the page's language, else its base language, else the site's. A folder takes `tags:` too. A query reads one folder with `scope: archive` — the folder and everything inside it. A record sits in one folder; if you want a computed subset, that is a query, not a second placement. Structure is for querying, not for navigation — and `folder.yml` never lists a record at the top level (the build refuses it: every file in `records/` is a record already).
|
|
911
915
|
|
|
912
916
|
⚠️ **With a backend, `records/` is what `uniweb push` sends** — every record in it. A site with no `records/` at all leaves the backend's records alone; an *empty* `records/` removes them, and the CLI asks before it does. Records pushed to a backend are served once the site is published.
|
|
913
917
|
|
|
@@ -171,3 +171,52 @@ export async function downloadMissingAssets({
|
|
|
171
171
|
updateBackendMap(siteDir, origin, 'assets', learned, carryServed)
|
|
172
172
|
return out
|
|
173
173
|
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Fetch the files a pull's file records name (`@uniweb/file`) to where each lands in
|
|
177
|
+
* `records/uniweb/file/` — the list `recordsToProject` returns as `fileDownloads` — and record each
|
|
178
|
+
* one's asset under its key (`sync.json` `assets`), as a push records the upload, so the next pull
|
|
179
|
+
* knows the bytes on disk are that asset and fetches nothing.
|
|
180
|
+
*
|
|
181
|
+
* @param {object} opts
|
|
182
|
+
* @param {Array<{ url: string, path: string, ref: string, assetId?: string, assetExt?: string }>} opts.downloads
|
|
183
|
+
* @param {string} opts.siteDir
|
|
184
|
+
* @param {string} opts.origin - backend origin, to resolve an origin-relative URL
|
|
185
|
+
* @param {typeof fetch} [opts.fetchImpl]
|
|
186
|
+
* @param {(m: string) => void} [opts.onProgress]
|
|
187
|
+
* @param {(m: string) => void} [opts.warn]
|
|
188
|
+
* @returns {Promise<{ downloaded: string[], failed: string[] }>} paths
|
|
189
|
+
*/
|
|
190
|
+
export async function downloadFileRecords({
|
|
191
|
+
downloads = [],
|
|
192
|
+
siteDir,
|
|
193
|
+
origin,
|
|
194
|
+
fetchImpl,
|
|
195
|
+
onProgress = () => {},
|
|
196
|
+
warn = () => {}
|
|
197
|
+
}) {
|
|
198
|
+
const doFetch = fetchImpl || ((u) => globalThis.fetch(u))
|
|
199
|
+
const out = { downloaded: [], failed: [] }
|
|
200
|
+
const learned = {}
|
|
201
|
+
for (const d of downloads) {
|
|
202
|
+
try {
|
|
203
|
+
const res = await doFetch(new URL(d.url, origin).href)
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
warn(`file ${d.ref}: HTTP ${res.status} — not fetched`)
|
|
206
|
+
out.failed.push(d.path)
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
const bytes = Buffer.from(await res.arrayBuffer())
|
|
210
|
+
mkdirSync(dirname(d.path), { recursive: true })
|
|
211
|
+
writeFileSync(d.path, bytes)
|
|
212
|
+
onProgress(`↓ ${d.ref}`)
|
|
213
|
+
out.downloaded.push(d.path)
|
|
214
|
+
if (d.assetId) learned[d.ref] = { id: d.assetId, ext: d.assetExt || '' }
|
|
215
|
+
} catch (err) {
|
|
216
|
+
warn(`file ${d.ref}: ${err.message} — not fetched`)
|
|
217
|
+
out.failed.push(d.path)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (Object.keys(learned).length) updateBackendMap(siteDir, origin, 'assets', learned, carryServed)
|
|
221
|
+
return out
|
|
222
|
+
}
|
|
@@ -24,7 +24,8 @@ import { humanBytes } from '../utils/bytes.js'
|
|
|
24
24
|
* @param {object} client - BackendClient (uploadSiteAssets). No `discover` — this
|
|
25
25
|
* lane stopped consulting the capability doc when `assetBase` was removed.
|
|
26
26
|
* @param {string} siteDir - the site root (site-root refs resolve under public/)
|
|
27
|
-
* @param {string
|
|
27
|
+
* @param {Array<string|{ ref: string, path: string, contentType?: string }>} refs - site-root local
|
|
28
|
+
* asset refs (`/images/x.png`), or files already located (a file record's)
|
|
28
29
|
* @param {{ siteUuid?: string|null, onProgress?: (m: string) => void, warn?: (m: string) => void }} [opts]
|
|
29
30
|
* `siteUuid` is the owner the uploaded bytes are charged to. Callers create the
|
|
30
31
|
* site before uploading precisely so this is set — an unowned upload is charged
|
|
@@ -47,8 +48,15 @@ export async function uploadSiteMedia(
|
|
|
47
48
|
|
|
48
49
|
const files = []
|
|
49
50
|
const missing = []
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// ⭐ A ref is a site-root media path (`/images/x.png`), resolved under the site — or a file the
|
|
52
|
+
// caller already located, `{ ref, path, contentType }`: a file record's file and its preview, which
|
|
53
|
+
// live in the records directory and declare their own type (`@uniweb/file`, 2026-09-28).
|
|
54
|
+
const keys = []
|
|
55
|
+
for (const item of refs) {
|
|
56
|
+
const ref = typeof item === 'string' ? item : item?.ref
|
|
57
|
+
if (typeof ref !== 'string' || !ref) continue
|
|
58
|
+
keys.push(ref)
|
|
59
|
+
const resolved = typeof item === 'string' ? resolveAssetPath(ref, siteDir, siteDir).resolved : item.path
|
|
52
60
|
if (!resolved || !existsSync(resolved)) {
|
|
53
61
|
warn?.(`local-media: ${ref} not found under the site (skipped)`)
|
|
54
62
|
missing.push(ref)
|
|
@@ -57,7 +65,7 @@ export async function uploadSiteMedia(
|
|
|
57
65
|
const bytes = readFileSync(resolved)
|
|
58
66
|
files.push({
|
|
59
67
|
path: ref.replace(/^\/+/, ''), // bookkeeping key into the plan (must be unique)
|
|
60
|
-
content_type: contentTypeFor(basename(resolved)),
|
|
68
|
+
content_type: (typeof item === 'object' && item.contentType) || contentTypeFor(basename(resolved)),
|
|
61
69
|
size: bytes.length,
|
|
62
70
|
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
63
71
|
localUrl: ref, // the rewrite key — the original content ref
|
|
@@ -77,7 +85,7 @@ export async function uploadSiteMedia(
|
|
|
77
85
|
// dedup skip, so a re-push of unchanged media still records identity without
|
|
78
86
|
// moving bytes — which is what makes the committed map cheap to keep accurate.
|
|
79
87
|
const ids = {}
|
|
80
|
-
for (const ref of
|
|
88
|
+
for (const ref of keys) {
|
|
81
89
|
const entry = result.assetsByLocalUrl[ref]
|
|
82
90
|
if (!entry) continue
|
|
83
91
|
// `served` — a fingerprint of the address, never the address. It is how a pull
|
package/src/backend/site-sync.js
CHANGED
|
@@ -49,7 +49,10 @@ import {
|
|
|
49
49
|
matchStoredRecords,
|
|
50
50
|
parseCatalogRef,
|
|
51
51
|
readRegisteredFoundation,
|
|
52
|
-
writeRegisteredFoundation
|
|
52
|
+
writeRegisteredFoundation,
|
|
53
|
+
backfillLinkUuids,
|
|
54
|
+
LINK_MODEL,
|
|
55
|
+
bankedFileUuids
|
|
53
56
|
} from '@uniweb/build/uwx'
|
|
54
57
|
|
|
55
58
|
// First entity `$`-document out of a `.uwx` we produced or the backend served.
|
|
@@ -1419,7 +1422,9 @@ export async function recoverUnbankedIdentity({ client, siteDir, pkg, note }) {
|
|
|
1419
1422
|
}
|
|
1420
1423
|
// The records the package sends, and the folder placing them. The emit reads the `records`
|
|
1421
1424
|
// map from sync.json itself, so it needs no option — only the build again.
|
|
1422
|
-
|
|
1425
|
+
// Link records are matched as records are — by Model and name (`@uniweb/link`, `link-records.js`).
|
|
1426
|
+
const links = (pkg?.records?.links || []).map((l) => ({ ownId: l.ownId, model: LINK_MODEL, slug: l.slug }))
|
|
1427
|
+
if (await recoverRecordsIdentity({ client, siteDir, index: [...(pkg?.records?.index || []), ...links], note })) {
|
|
1423
1428
|
again = {
|
|
1424
1429
|
...again,
|
|
1425
1430
|
folderItemUuids: readFolderItemUuids(siteDir, client.origin),
|
|
@@ -1754,7 +1759,7 @@ export async function pushSyncPackages({
|
|
|
1754
1759
|
// Two unrelated conflicts share HTTP 409, so branch on the machine-readable
|
|
1755
1760
|
// `reason` — never on `detail`, which is prose the backend may reword.
|
|
1756
1761
|
let problem = null
|
|
1757
|
-
if ((res.status === 409 || res.status === 400) && body) {
|
|
1762
|
+
if ((res.status === 409 || res.status === 400 || res.status === 422) && body) {
|
|
1758
1763
|
try {
|
|
1759
1764
|
problem = JSON.parse(body)
|
|
1760
1765
|
} catch {
|
|
@@ -1934,6 +1939,15 @@ export async function pushSyncPackages({
|
|
|
1934
1939
|
` deleted there → uniweb forget --backend ${client.origin}, then push again: it creates a NEW site`
|
|
1935
1940
|
)
|
|
1936
1941
|
note('Deleting this folder removes only your local copy, either way.')
|
|
1942
|
+
} else if (problem?.reason === 'template_records_not_copyable' && Array.isArray(problem.records)) {
|
|
1943
|
+
// ⭐ A push asking to designate the site a template is refused whole when a record in its
|
|
1944
|
+
// folder cannot be copied into the sites made from it — each named, with a `detail` written
|
|
1945
|
+
// to be shown. Until 2026-09-28 this printed the raw problem document, cut at 800 characters.
|
|
1946
|
+
note(problem.title || 'These records cannot be copied into a template:')
|
|
1947
|
+
for (const r of problem.records) {
|
|
1948
|
+
note(` ${r?.name ?? '(unnamed)'} — ${r?.detail || r?.reason || 'no reason given'}`)
|
|
1949
|
+
}
|
|
1950
|
+
return null
|
|
1937
1951
|
} else if (res.status === 409 && (problem?.title || problem?.detail)) {
|
|
1938
1952
|
// ⛔ A 409 names its own cause — a reference the store refuses, a rule of the record's type,
|
|
1939
1953
|
// an append-only section — so it is shown as the backend put it, not explained by us. Until
|
|
@@ -2240,6 +2254,20 @@ export async function pushSyncPackages({
|
|
|
2240
2254
|
const placements = collectFolderItemUuids(folderDoc)
|
|
2241
2255
|
if (Object.keys(placements).length)
|
|
2242
2256
|
writeFolderItemUuids(siteDir, client.origin, placements)
|
|
2257
|
+
// ⭐ A LINK RECORD'S IDENTITY is its folder entry's `$uuid`, banked as a record's is: into
|
|
2258
|
+
// its file the first time, then mapped per backend (`link-records.js`, 2026-09-28).
|
|
2259
|
+
if (records.links?.length) {
|
|
2260
|
+
const lb = backfillLinkUuids({ links: records.links, folderDoc })
|
|
2261
|
+
if (Object.keys(lb.mapped).length) updateBackendMap(siteDir, client.origin, 'records', lb.mapped)
|
|
2262
|
+
for (const w of lb.warnings) note(`! ${w}`)
|
|
2263
|
+
if (lb.updated.length) wrote.push(`wrote ${lb.updated.length} link file(s)`)
|
|
2264
|
+
}
|
|
2265
|
+
// ⭐ …and a FILE RECORD's: a file cannot carry it, so it is banked per backend by the file's
|
|
2266
|
+
// path (`sync.json` `files`; `file-records.js`, 2026-09-28).
|
|
2267
|
+
if (records.files?.length) {
|
|
2268
|
+
const fb = bankedFileUuids({ files: records.files, folderDoc })
|
|
2269
|
+
if (Object.keys(fb).length) updateBackendMap(siteDir, client.origin, 'files', fb)
|
|
2270
|
+
}
|
|
2243
2271
|
}
|
|
2244
2272
|
finalizedTotal += finalized.length
|
|
2245
2273
|
}
|
package/src/commands/publish.js
CHANGED
|
@@ -656,7 +656,8 @@ export async function publish(args = []) {
|
|
|
656
656
|
// the ref→serveUrl map; rewrite the entity content AND the ball with it.
|
|
657
657
|
let assetRewrite = null
|
|
658
658
|
let assetIds = null
|
|
659
|
-
|
|
659
|
+
// …and each file record's file (`@uniweb/file` — `{ ref, path, contentType }`, already located).
|
|
660
|
+
const mediaRefs = [...new Set([...localAssets, ...ballAssets]), ...(probe.localFiles || [])]
|
|
660
661
|
if (mediaRefs.length) {
|
|
661
662
|
say.info('Uploading media…')
|
|
662
663
|
try {
|
package/src/commands/pull.js
CHANGED
|
@@ -69,7 +69,7 @@ import { createHash } from 'node:crypto'
|
|
|
69
69
|
import { createInterface } from 'node:readline/promises'
|
|
70
70
|
import { join, dirname, relative, resolve } from 'node:path'
|
|
71
71
|
import yaml from 'js-yaml'
|
|
72
|
-
import { downloadMissingAssets } from '../backend/asset-download.js'
|
|
72
|
+
import { downloadMissingAssets, downloadFileRecords } from '../backend/asset-download.js'
|
|
73
73
|
import {
|
|
74
74
|
readBackendState,
|
|
75
75
|
siteContentDocumentToProject,
|
|
@@ -1068,6 +1068,25 @@ export async function pull(args = [], deps = {}) {
|
|
|
1068
1068
|
removed.push(resolve(siteDir, report.recordsFile))
|
|
1069
1069
|
}
|
|
1070
1070
|
records += report.placed.length + report.updated.length
|
|
1071
|
+
// ⭐ A FILE RECORD'S BYTES (`@uniweb/file`, 2026-09-28): fetched to where `folder.yml` now
|
|
1072
|
+
// places each one, as media are fetched. A file that did not arrive is a record not taken —
|
|
1073
|
+
// reported, and the pull fails below as it does for any, so the next one fetches it again.
|
|
1074
|
+
if (report.fileDownloads?.length) {
|
|
1075
|
+
const got = await downloadFileRecords({
|
|
1076
|
+
downloads: report.fileDownloads,
|
|
1077
|
+
siteDir,
|
|
1078
|
+
origin: client.origin,
|
|
1079
|
+
onProgress: (m) => note(` ${m}`),
|
|
1080
|
+
warn: (m) => note(`! ${m}`)
|
|
1081
|
+
})
|
|
1082
|
+
records += got.downloaded.length
|
|
1083
|
+
wrote.push(...got.downloaded)
|
|
1084
|
+
if (got.failed.length) {
|
|
1085
|
+
report.skipped.push(
|
|
1086
|
+
...got.failed.map((path) => ({ slug: relative(siteDir, path), reason: 'its file could not be fetched' }))
|
|
1087
|
+
)
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1071
1090
|
// ⛔ A PULL THAT DID NOT PLACE A RECORD HAS NOT TAKEN THE BACKEND'S RECORDS.
|
|
1072
1091
|
// Until 2026-09-23 each skip was a dim note, the pull said "✓ Pulled … 0
|
|
1073
1092
|
// record(s)" and exited 0, and it banked the lane's ETag and tokens. So the next
|
package/src/commands/push.js
CHANGED
|
@@ -409,7 +409,8 @@ export async function push(args = [], deps = {}) {
|
|
|
409
409
|
...(foundationDir ? { foundationDir } : {}),
|
|
410
410
|
resolveModel: makeModelResolver({ client, offline: false, siteDir })
|
|
411
411
|
})
|
|
412
|
-
|
|
412
|
+
// Site-root media, and each file record's file (`@uniweb/file` — `{ ref, path, contentType }`).
|
|
413
|
+
mediaRefs = [...(probe.localAssets || []), ...(probe.localFiles || [])]
|
|
413
414
|
refusals = probe.refusals || []
|
|
414
415
|
} catch (err) {
|
|
415
416
|
error(`Could not scan the site for local media: ${err.message}`)
|
package/src/framework-index.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
|
-
"generatedAt": "2026-09-
|
|
3
|
+
"generatedAt": "2026-09-28T21:05:39.664Z",
|
|
4
4
|
"packages": {
|
|
5
5
|
"@uniweb/api": {
|
|
6
6
|
"version": "0.6.8",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
]
|
|
11
11
|
},
|
|
12
12
|
"@uniweb/build": {
|
|
13
|
-
"version": "0.
|
|
13
|
+
"version": "0.73.1",
|
|
14
14
|
"path": "framework/build",
|
|
15
15
|
"deps": [
|
|
16
16
|
"@uniweb/content-reader",
|
|
@@ -97,7 +97,7 @@
|
|
|
97
97
|
"deps": []
|
|
98
98
|
},
|
|
99
99
|
"@uniweb/schemas": {
|
|
100
|
-
"version": "0.9.
|
|
100
|
+
"version": "0.9.2",
|
|
101
101
|
"path": "framework/schemas",
|
|
102
102
|
"deps": []
|
|
103
103
|
},
|
|
@@ -127,7 +127,7 @@
|
|
|
127
127
|
"deps": []
|
|
128
128
|
},
|
|
129
129
|
"@uniweb/unipress": {
|
|
130
|
-
"version": "0.10.
|
|
130
|
+
"version": "0.10.16",
|
|
131
131
|
"path": "framework/unipress",
|
|
132
132
|
"deps": [
|
|
133
133
|
"@uniweb/build",
|
|
@@ -4,7 +4,9 @@ dist
|
|
|
4
4
|
.DS_Store
|
|
5
5
|
*.local
|
|
6
6
|
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# Generated from records/ on every build — compiled queries in public/data/, and
|
|
8
|
+
# each record's files, published in public/records/. Both land in the source tree
|
|
9
|
+
# rather than dist/, so without this they look permanent, get committed, and then
|
|
10
|
+
# look like something you may edit. They aren't: records live in records/.
|
|
10
11
|
public/data/
|
|
12
|
+
public/records/
|