uniweb 1.0.0 → 1.0.2

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.
Files changed (129) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +532 -3
  3. package/package.json +60 -11
  4. package/partials/adding-pages.hbs +77 -0
  5. package/partials/agents.md +3000 -0
  6. package/partials/ai-assistance.hbs +28 -0
  7. package/partials/components-docs.hbs +13 -0
  8. package/partials/config-reference.hbs +66 -0
  9. package/partials/deployment.hbs +13 -0
  10. package/partials/learn-more.hbs +6 -0
  11. package/partials/quick-start.hbs +8 -0
  12. package/partials/search-docs.hbs +110 -0
  13. package/src/backend/asset-download.js +222 -0
  14. package/src/backend/client.js +986 -0
  15. package/src/backend/foundation-bring-along.js +692 -0
  16. package/src/backend/payment-handoff.js +362 -0
  17. package/src/backend/service-request.js +210 -0
  18. package/src/backend/site-media.js +255 -0
  19. package/src/backend/site-preview.js +77 -0
  20. package/src/backend/site-sync.js +2515 -0
  21. package/src/backend/workspace.js +234 -0
  22. package/src/commands/add.js +1900 -0
  23. package/src/commands/build.js +1214 -0
  24. package/src/commands/clone.js +466 -0
  25. package/src/commands/content.js +202 -0
  26. package/src/commands/deploy.js +537 -0
  27. package/src/commands/dev.js +202 -0
  28. package/src/commands/docs.js +530 -0
  29. package/src/commands/doctor.js +1982 -0
  30. package/src/commands/export.js +116 -0
  31. package/src/commands/families.js +82 -0
  32. package/src/commands/forget.js +197 -0
  33. package/src/commands/handoff.js +21 -0
  34. package/src/commands/i18n.js +1707 -0
  35. package/src/commands/inspect.js +375 -0
  36. package/src/commands/invite.js +24 -0
  37. package/src/commands/org.js +96 -0
  38. package/src/commands/publish.js +1087 -0
  39. package/src/commands/pull.js +1257 -0
  40. package/src/commands/push.js +663 -0
  41. package/src/commands/refresh.js +212 -0
  42. package/src/commands/register.js +1092 -0
  43. package/src/commands/rename.js +647 -0
  44. package/src/commands/site.js +358 -0
  45. package/src/commands/snapshot.js +461 -0
  46. package/src/commands/starter.js +206 -0
  47. package/src/commands/status.js +302 -0
  48. package/src/commands/sync.js +93 -0
  49. package/src/commands/template.js +107 -0
  50. package/src/commands/update.js +887 -0
  51. package/src/commands/validate.js +376 -0
  52. package/src/framework-index.json +248 -0
  53. package/src/index.js +2241 -0
  54. package/src/templates/fetchers/github.js +113 -0
  55. package/src/templates/fetchers/npm.js +85 -0
  56. package/src/templates/fetchers/release.js +92 -0
  57. package/src/templates/index.js +190 -0
  58. package/src/templates/processor.js +333 -0
  59. package/src/templates/resolver.js +232 -0
  60. package/src/templates/validator.js +155 -0
  61. package/src/utils/agents-stamp.js +110 -0
  62. package/src/utils/args.js +126 -0
  63. package/src/utils/asset-upload.js +270 -0
  64. package/src/utils/bytes.js +30 -0
  65. package/src/utils/code-upload.js +394 -0
  66. package/src/utils/config.js +417 -0
  67. package/src/utils/conformance.js +274 -0
  68. package/src/utils/dep-survey.js +176 -0
  69. package/src/utils/destination-prompt.js +146 -0
  70. package/src/utils/discover.js +75 -0
  71. package/src/utils/env.js +22 -0
  72. package/src/utils/exit.js +30 -0
  73. package/src/utils/fetch-retry.js +109 -0
  74. package/src/utils/flag-guard.js +246 -0
  75. package/src/utils/foundation-name.js +179 -0
  76. package/src/utils/git.js +342 -0
  77. package/src/utils/host-prompt.js +51 -0
  78. package/src/utils/install-integrity.js +210 -0
  79. package/src/utils/interactive.js +118 -0
  80. package/src/utils/json-file.js +72 -0
  81. package/src/utils/names.js +91 -0
  82. package/src/utils/placement.js +151 -0
  83. package/src/utils/pm.js +165 -0
  84. package/src/utils/preview.js +20 -0
  85. package/src/utils/pull-written.js +132 -0
  86. package/src/utils/records-guard.js +88 -0
  87. package/src/utils/registry-auth.js +994 -0
  88. package/src/utils/registry-orgs.js +290 -0
  89. package/src/utils/scaffold.js +483 -0
  90. package/src/utils/schemaless-report.js +30 -0
  91. package/src/utils/semver-precedence.js +80 -0
  92. package/src/utils/session-file.js +95 -0
  93. package/src/utils/site-content-refs.js +23 -0
  94. package/src/utils/site-copies.js +123 -0
  95. package/src/utils/site-data-upload.js +302 -0
  96. package/src/utils/site-identity.js +137 -0
  97. package/src/utils/snapshot-settings.js +112 -0
  98. package/src/utils/update-check.js +302 -0
  99. package/src/utils/uwx-read.js +157 -0
  100. package/src/utils/workspace.js +223 -0
  101. package/src/versions.js +467 -0
  102. package/starter/foundation/main.js +50 -0
  103. package/starter/foundation/sections/Section/index.jsx +95 -0
  104. package/starter/foundation/sections/Section/meta.js +44 -0
  105. package/starter/site/pages/about/1-about.md.hbs +13 -0
  106. package/starter/site/pages/about/page.yml +3 -0
  107. package/starter/site/pages/home/1-welcome.md.hbs +14 -0
  108. package/starter/site/pages/home/page.yml +3 -0
  109. package/templates/foundation/_gitignore +5 -0
  110. package/templates/foundation/components/.gitkeep +0 -0
  111. package/templates/foundation/main.js.hbs +33 -0
  112. package/templates/foundation/package.json.hbs +51 -0
  113. package/templates/foundation/sections/.gitkeep +0 -0
  114. package/templates/foundation/styles.css +6 -0
  115. package/templates/foundation/vite.config.js +3 -0
  116. package/templates/site/_gitignore +12 -0
  117. package/templates/site/entry.js +7 -0
  118. package/templates/site/index.html.hbs +13 -0
  119. package/templates/site/package.json.hbs +27 -0
  120. package/templates/site/public/favicon.svg +7 -0
  121. package/templates/site/site.yml.hbs +91 -0
  122. package/templates/site/theme.yml +99 -0
  123. package/templates/site/vite.config.js +7 -0
  124. package/templates/workspace/AGENTS.md.hbs +2 -0
  125. package/templates/workspace/README.md.hbs +87 -0
  126. package/templates/workspace/_gitignore +29 -0
  127. package/templates/workspace/_vscode/settings.json +6 -0
  128. package/templates/workspace/package.json.hbs +20 -0
  129. package/templates/workspace/pnpm-workspace.yaml.hbs +26 -0
@@ -0,0 +1,28 @@
1
+ ## Developer Guide
2
+
3
+ [AGENTS.md](./AGENTS.md) is a comprehensive guide to building with Uniweb — content authoring, component development, theming, configuration, and the kit API. Despite the name, it's written for developers and AI assistants alike. Read it to get productive fast.
4
+
5
+ ### AI Prompts
6
+
7
+ AI coding assistants (Claude Code, Cursor, Copilot, etc.) read AGENTS.md automatically. Some prompts to try:
8
+
9
+ **Converting an existing design:**
10
+ ```
11
+ I have a landing page design in `homepage.jsx`. Convert it into this Uniweb
12
+ project. Decompose each visual section into a foundation component and extract
13
+ all text content into markdown files in site/pages/home/. The final page
14
+ should render identically to the original. See AGENTS.md for the full guide.
15
+ ```
16
+
17
+ **Adding a new component:**
18
+ ```
19
+ Create a Pricing component with 3-column cards. It should support light and
20
+ dark themes. Use content.items for the pricing tiers — each item has a title,
21
+ paragraph (price), and links (CTA button).
22
+ ```
23
+
24
+ **Adding a new page:**
25
+ ```
26
+ Add a /contact page with a hero section and a form section. Reuse the Hero
27
+ component from the homepage. Create a new ContactForm component for the form.
28
+ ```
@@ -0,0 +1,13 @@
1
+ ## Component Documentation
2
+
3
+ Generate up-to-date documentation for all foundation components:
4
+
5
+ ```bash
6
+ # From foundation directory
7
+ uniweb docs
8
+
9
+ # Or from site directory (auto-detects linked foundation)
10
+ cd site && uniweb docs
11
+ ```
12
+
13
+ This creates `COMPONENTS.md` with details on each component's parameters, presets, and content elements. The documentation is generated from component `meta.js` files, so it's always current.
@@ -0,0 +1,66 @@
1
+ ## Configuration Reference
2
+
3
+ ### site.yml
4
+
5
+ Site-wide configuration in `site/site.yml`:
6
+
7
+ ```yaml
8
+ name: My Site # Site name
9
+ defaultLanguage: en # Default locale code
10
+ foundation: foundation # Foundation package name
11
+
12
+ # Page ordering (controls top-level navigation)
13
+ pages: [home, about, features] # First is homepage at /
14
+ # Or just name the homepage:
15
+ index: home
16
+
17
+ # Internationalization
18
+ i18n:
19
+ locales: [es, fr] # Non-default locales to build
20
+ localesDir: locales # Translation files directory
21
+
22
+ # Build options
23
+ build:
24
+ prerender: true # Enable static site generation
25
+ ```
26
+
27
+ ### page.yml
28
+
29
+ Page-level configuration in `site/pages/[page]/page.yml`:
30
+
31
+ ```yaml
32
+ title: About Us # Page title (used in <title> and nav)
33
+ description: Learn about us # Meta description
34
+ label: About # Short nav label (optional)
35
+ order: 2 # Navigation sort order
36
+
37
+ # Child page ordering
38
+ pages: [intro, guide, reference] # First is index for this route
39
+ index: intro # Or just name the index
40
+
41
+ # Navigation visibility
42
+ hidden: true # Hide from all navigation
43
+ hideIn: [header] # Hide from named nav areas only (header, footer, sidebar, …)
44
+
45
+ # Layout overrides
46
+ layout:
47
+ header: false # Don't render header
48
+ footer: false # Don't render footer
49
+
50
+ # Section ordering
51
+ sections: # Explicit section order
52
+ - hero
53
+ - features: # Nested = subsections
54
+ - logocloud
55
+ - stats
56
+ - pricing
57
+
58
+ # SEO
59
+ seo:
60
+ noindex: true
61
+ image: /og-image.png
62
+ changefreq: monthly
63
+ priority: 0.8
64
+ ```
65
+
66
+ Run `uniweb docs site` or `uniweb docs page` for complete reference.
@@ -0,0 +1,13 @@
1
+ ## Deployment
2
+
3
+ Build and deploy the `site/dist` folder:
4
+
5
+ ```bash
6
+ pnpm build
7
+ ```
8
+
9
+ **Supported platforms:**
10
+ - **Vercel**: `cd site && vercel`
11
+ - **Netlify**: Build command `pnpm build`, publish `site/dist`
12
+ - **GitHub Pages**: Use GitHub Actions
13
+ - **Cloudflare Pages**: Connect repo, set `pnpm build` and `site/dist`
@@ -0,0 +1,6 @@
1
+ ## Learn More
2
+
3
+ - [AGENTS.md](./AGENTS.md) — comprehensive guide to building with Uniweb
4
+ - [Uniweb Documentation](https://github.com/uniweb/docs) — full reference docs
5
+ - [Uniweb CLI](https://github.com/uniweb/cli) — project scaffolding and build tools
6
+ - [Tailwind CSS v4](https://tailwindcss.com)
@@ -0,0 +1,8 @@
1
+ ## Quick Start
2
+
3
+ ```bash
4
+ pnpm install # Install dependencies
5
+ pnpm dev # Start development server
6
+ ```
7
+
8
+ Visit `http://localhost:5173` to see your site.
@@ -0,0 +1,110 @@
1
+ ## Search Functionality
2
+
3
+ Uniweb sites support full-text search out of the box. Search indexes are generated automatically at build time.
4
+
5
+ ### Enabling Search in Your Foundation
6
+
7
+ To add search to your site, your foundation needs the **Fuse.js** library:
8
+
9
+ ```bash
10
+ cd foundation
11
+ pnpm add fuse.js
12
+ ```
13
+
14
+ Then use the search helpers from `@uniweb/kit`:
15
+
16
+ ```jsx
17
+ import { useSearch } from '@uniweb/kit'
18
+
19
+ function SearchComponent({ website }) {
20
+ const { query, results, isLoading, clear } = useSearch(website)
21
+
22
+ return (
23
+ <div>
24
+ <input
25
+ type="search"
26
+ placeholder="Search..."
27
+ onChange={e => query(e.target.value)}
28
+ />
29
+ {isLoading && <span>Searching...</span>}
30
+ <ul>
31
+ {results.map(result => (
32
+ <li key={result.id}>
33
+ <a href={result.href}>{result.title}</a>
34
+ <p dangerouslySetInnerHTML=\{{ __html: result.snippetHtml }} />
35
+ </li>
36
+ ))}
37
+ </ul>
38
+ </div>
39
+ )
40
+ }
41
+ ```
42
+
43
+ ### Search Configuration
44
+
45
+ Search is enabled by default. To customize, add to `site/site.yml`:
46
+
47
+ ```yaml
48
+ search:
49
+ enabled: true
50
+ exclude:
51
+ routes: [/admin, /private] # Don't index these routes
52
+ components: [Debug] # Don't index these component types
53
+ ```
54
+
55
+ ### How It Works
56
+
57
+ 1. **Build time**: Uniweb extracts text from all pages and sections
58
+ 2. **Output**: A `search-index.json` file is generated in `dist/`
59
+ 3. **Runtime**: Components fetch and search the index client-side
60
+ 4. **Multi-locale**: Each locale gets its own search index
61
+
62
+ ### Search API
63
+
64
+ The `website` object provides these methods:
65
+
66
+ ```javascript
67
+ // Check if search is available
68
+ website.isSearchEnabled() // Returns boolean
69
+
70
+ // Get the URL for the search index
71
+ website.getSearchIndexUrl() // "/search-index.json" or "/es/search-index.json"
72
+
73
+ // Get full search configuration
74
+ website.getSearchConfig() // { enabled, indexUrl, locale, include, exclude }
75
+ ```
76
+
77
+ ### Search Result Format
78
+
79
+ Results from `useSearch()` include:
80
+
81
+ | Field | Description |
82
+ |-------|-------------|
83
+ | `id` | Unique result identifier |
84
+ | `type` | `"page"` or `"section"` |
85
+ | `route` | Page route |
86
+ | `href` | Full link including anchor |
87
+ | `title` | Result title |
88
+ | `pageTitle` | Parent page title (for sections) |
89
+ | `snippetHtml` | Excerpt with `<mark>` highlighted matches |
90
+ | `snippetText` | Plain text excerpt |
91
+
92
+ ### Without @uniweb/kit
93
+
94
+ If you prefer not to use the kit helpers, you can fetch the index directly:
95
+
96
+ ```javascript
97
+ // Fetch the search index
98
+ const response = await fetch(website.getSearchIndexUrl())
99
+ const index = await response.json()
100
+
101
+ // Use with Fuse.js directly
102
+ import Fuse from 'fuse.js'
103
+ const fuse = new Fuse(index.entries, {
104
+ keys: ['title', 'content'],
105
+ threshold: 0.35,
106
+ includeMatches: true
107
+ })
108
+
109
+ const results = fuse.search('your query')
110
+ ```
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Fetch the asset bytes a site project does not have locally.
3
+ *
4
+ * `pull` and `clone` bring content down; this brings the media with it, so a
5
+ * teammate who clones gets a project that renders instead of one full of images
6
+ * pointing at a host they may not even be able to reach.
7
+ *
8
+ * ## Where an asset lands, and why there are two answers
9
+ *
10
+ * - **The map knows it** (this backend's `assets` in `sync.json`) — the file goes
11
+ * back to the path its author wrote. That is the whole reason the map is
12
+ * committed: a fresh clone restores `public/images/hero.png`, not a hash.
13
+ * - **The map does not know it** — this project has never held these bytes:
14
+ * authored in the app, or pushed from a machine whose map entry has not
15
+ * arrived. There is no local path to guess, so it lands at a **generic,
16
+ * content-addressed** one (`/assets/{id}.{ext}`) and the map gains the entry.
17
+ * From then on it is a known asset like any other.
18
+ *
19
+ * ## ⛔ The URL is READ from the content, never composed
20
+ *
21
+ * Identity rides beside the reference (`sync-package.js` stamps `assetId` next
22
+ * to the serve URL), so the address is already on the node. Composing one would
23
+ * mean holding the host's route layout — the coupling deleting `buildAssetUrl`
24
+ * removed from this CLI, and the reason the asset map stores no URL either (only a
25
+ * fingerprint of one). An id with no URL beside it is skipped, not guessed at.
26
+ *
27
+ * ## ⛔ A failed download is a WARNING, never a failed pull
28
+ *
29
+ * The content still carries the URL, so a site whose bytes did not arrive still
30
+ * renders — from the host, as it did before. Making the pull fail would turn a
31
+ * degraded outcome into no outcome, and the degraded one is genuinely usable.
32
+ */
33
+
34
+ import { mkdirSync, existsSync, writeFileSync } from 'node:fs'
35
+ import { dirname, join } from 'node:path'
36
+ import { ASSET_SLOTS } from '@uniweb/semantic-parser'
37
+ import {
38
+ readBackendState,
39
+ updateBackendMap,
40
+ carryServed,
41
+ servedFingerprint
42
+ } from '@uniweb/build/uwx'
43
+
44
+ /**
45
+ * Collect every `{ id, ext, url }` an entity document references.
46
+ *
47
+ * Reads the same shape `rewriteEntityAssets` writes — identity on the object
48
+ * carrying the reference — so a ProseMirror image node's attrs and a section
49
+ * background's media object are both found by one walk.
50
+ */
51
+ export function collectAssetRefs(document, map = {}) {
52
+ const found = new Map() // id → { id, ext, url }
53
+ const visit = (node) => {
54
+ if (Array.isArray(node)) return node.forEach(visit)
55
+ if (!node || typeof node !== 'object') return
56
+ for (const slot of ASSET_SLOTS) {
57
+ const id = typeof node[slot.id] === 'string' ? node[slot.id] : null
58
+ if (!id || found.has(id)) continue
59
+ const url = slot.urls
60
+ .map((k) => (typeof node[k] === 'string' ? node[k] : null))
61
+ .find(Boolean)
62
+ found.set(id, { id, ext: node[slot.ext] || '', url })
63
+ }
64
+ for (const v of Object.values(node)) visit(v)
65
+ }
66
+ visit(document)
67
+ // A BARE-STRING reference (`info.preview`, `seo.image`, a section param) has no
68
+ // object to carry identity beside it. The map recognizes it by the fingerprint the
69
+ // push recorded for its serve URL — see `servedFingerprint` in @uniweb/build/uwx.
70
+ const byServed = new Map()
71
+ for (const v of Object.values(map || {})) {
72
+ if (v?.served && v.id && !byServed.has(v.served)) byServed.set(v.served, v)
73
+ }
74
+ if (byServed.size) {
75
+ const visitStrings = (node) => {
76
+ if (typeof node === 'string') {
77
+ if (!node.startsWith('/') && !/^https?:\/\//i.test(node)) return
78
+ const entry = byServed.get(servedFingerprint(node))
79
+ if (entry && !found.has(entry.id))
80
+ found.set(entry.id, { id: entry.id, ext: entry.ext || '', url: node })
81
+ return
82
+ }
83
+ if (Array.isArray(node)) return node.forEach(visitStrings)
84
+ if (node && typeof node === 'object')
85
+ for (const v of Object.values(node)) visitStrings(v)
86
+ }
87
+ visitStrings(document)
88
+ }
89
+ return [...found.values()]
90
+ }
91
+
92
+ /** Where a site-root ref lives on disk. `resolveAssetPath` looks in public/ first. */
93
+ const diskPathFor = (siteDir, ref) => join(siteDir, 'public', ref)
94
+
95
+ /** The generic landing path for an asset this project has never held. */
96
+ export const genericRefFor = (id, ext) => `/assets/${id}${ext ? `.${ext}` : ''}`
97
+
98
+ /**
99
+ * Download the assets `document` references that are not already on disk.
100
+ *
101
+ * @param {object} opts
102
+ * @param {object} opts.document - the site-content document (not mutated)
103
+ * @param {string} opts.siteDir - the site root
104
+ * @param {string} opts.origin - backend origin, to resolve an origin-relative URL
105
+ * @param {typeof fetch} [opts.fetchImpl]
106
+ * @param {(m: string) => void} [opts.onProgress]
107
+ * @param {(m: string) => void} [opts.warn]
108
+ * @returns {Promise<{ downloaded: string[], present: string[], failed: string[], skipped: string[] }>}
109
+ */
110
+ export async function downloadMissingAssets({
111
+ document,
112
+ siteDir,
113
+ origin,
114
+ fetchImpl,
115
+ onProgress = () => {},
116
+ warn = () => {}
117
+ }) {
118
+ const doFetch = fetchImpl || ((u) => globalThis.fetch(u))
119
+ // Read first: a bare-string reference is only recognizable through the map.
120
+ const map = readBackendState(siteDir, origin).assets || {}
121
+ const refs = collectAssetRefs(document, map)
122
+ const out = { downloaded: [], present: [], failed: [], skipped: [] }
123
+ if (!refs.length) return out
124
+
125
+ const byId = new Map()
126
+ for (const [ref, v] of Object.entries(map)) if (v?.id) byId.set(v.id, ref)
127
+
128
+ const learned = {}
129
+
130
+ for (const { id, ext, url } of refs) {
131
+ const known = byId.get(id)
132
+ const ref = known || genericRefFor(id, ext)
133
+ const disk = diskPathFor(siteDir, ref)
134
+
135
+ if (existsSync(disk)) {
136
+ out.present.push(ref)
137
+ // Still record identity for a generic ref we already hold but never mapped.
138
+ if (!known) learned[ref] = { id, ext }
139
+ continue
140
+ }
141
+ if (!url) {
142
+ // Identity with no address beside it. Composing one would mean holding the
143
+ // host's route layout; skipping is the honest answer.
144
+ warn(`asset ${id}: no URL in content to fetch from (skipped)`)
145
+ out.skipped.push(ref)
146
+ continue
147
+ }
148
+
149
+ try {
150
+ const res = await doFetch(new URL(url, origin).href)
151
+ if (!res.ok) {
152
+ warn(`asset ${id}: HTTP ${res.status} (kept the URL in content)`)
153
+ out.failed.push(ref)
154
+ continue
155
+ }
156
+ const bytes = Buffer.from(await res.arrayBuffer())
157
+ mkdirSync(dirname(disk), { recursive: true })
158
+ writeFileSync(disk, bytes)
159
+ onProgress(`↓ ${ref}`)
160
+ out.downloaded.push(ref)
161
+ if (!known) learned[ref] = { id, ext }
162
+ } catch (err) {
163
+ warn(`asset ${id}: ${err.message} (kept the URL in content)`)
164
+ out.failed.push(ref)
165
+ }
166
+ }
167
+
168
+ // Newly-landed assets become known ones, so the next pull restores them to
169
+ // this path rather than fetching them again to a second location.
170
+ if (Object.keys(learned).length)
171
+ updateBackendMap(siteDir, origin, 'assets', learned, carryServed)
172
+ return out
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
+ }