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.
- package/LICENSE +201 -0
- package/README.md +532 -3
- package/package.json +60 -11
- package/partials/adding-pages.hbs +77 -0
- package/partials/agents.md +3000 -0
- package/partials/ai-assistance.hbs +28 -0
- package/partials/components-docs.hbs +13 -0
- package/partials/config-reference.hbs +66 -0
- package/partials/deployment.hbs +13 -0
- package/partials/learn-more.hbs +6 -0
- package/partials/quick-start.hbs +8 -0
- package/partials/search-docs.hbs +110 -0
- package/src/backend/asset-download.js +222 -0
- package/src/backend/client.js +986 -0
- package/src/backend/foundation-bring-along.js +692 -0
- package/src/backend/payment-handoff.js +362 -0
- package/src/backend/service-request.js +210 -0
- package/src/backend/site-media.js +255 -0
- package/src/backend/site-preview.js +77 -0
- package/src/backend/site-sync.js +2515 -0
- package/src/backend/workspace.js +234 -0
- package/src/commands/add.js +1900 -0
- package/src/commands/build.js +1214 -0
- package/src/commands/clone.js +466 -0
- package/src/commands/content.js +202 -0
- package/src/commands/deploy.js +537 -0
- package/src/commands/dev.js +202 -0
- package/src/commands/docs.js +530 -0
- package/src/commands/doctor.js +1982 -0
- package/src/commands/export.js +116 -0
- package/src/commands/families.js +82 -0
- package/src/commands/forget.js +197 -0
- package/src/commands/handoff.js +21 -0
- package/src/commands/i18n.js +1707 -0
- package/src/commands/inspect.js +375 -0
- package/src/commands/invite.js +24 -0
- package/src/commands/org.js +96 -0
- package/src/commands/publish.js +1087 -0
- package/src/commands/pull.js +1257 -0
- package/src/commands/push.js +663 -0
- package/src/commands/refresh.js +212 -0
- package/src/commands/register.js +1092 -0
- package/src/commands/rename.js +647 -0
- package/src/commands/site.js +358 -0
- package/src/commands/snapshot.js +461 -0
- package/src/commands/starter.js +206 -0
- package/src/commands/status.js +302 -0
- package/src/commands/sync.js +93 -0
- package/src/commands/template.js +107 -0
- package/src/commands/update.js +887 -0
- package/src/commands/validate.js +376 -0
- package/src/framework-index.json +248 -0
- package/src/index.js +2241 -0
- package/src/templates/fetchers/github.js +113 -0
- package/src/templates/fetchers/npm.js +85 -0
- package/src/templates/fetchers/release.js +92 -0
- package/src/templates/index.js +190 -0
- package/src/templates/processor.js +333 -0
- package/src/templates/resolver.js +232 -0
- package/src/templates/validator.js +155 -0
- package/src/utils/agents-stamp.js +110 -0
- package/src/utils/args.js +126 -0
- package/src/utils/asset-upload.js +270 -0
- package/src/utils/bytes.js +30 -0
- package/src/utils/code-upload.js +394 -0
- package/src/utils/config.js +417 -0
- package/src/utils/conformance.js +274 -0
- package/src/utils/dep-survey.js +176 -0
- package/src/utils/destination-prompt.js +146 -0
- package/src/utils/discover.js +75 -0
- package/src/utils/env.js +22 -0
- package/src/utils/exit.js +30 -0
- package/src/utils/fetch-retry.js +109 -0
- package/src/utils/flag-guard.js +246 -0
- package/src/utils/foundation-name.js +179 -0
- package/src/utils/git.js +342 -0
- package/src/utils/host-prompt.js +51 -0
- package/src/utils/install-integrity.js +210 -0
- package/src/utils/interactive.js +118 -0
- package/src/utils/json-file.js +72 -0
- package/src/utils/names.js +91 -0
- package/src/utils/placement.js +151 -0
- package/src/utils/pm.js +165 -0
- package/src/utils/preview.js +20 -0
- package/src/utils/pull-written.js +132 -0
- package/src/utils/records-guard.js +88 -0
- package/src/utils/registry-auth.js +994 -0
- package/src/utils/registry-orgs.js +290 -0
- package/src/utils/scaffold.js +483 -0
- package/src/utils/schemaless-report.js +30 -0
- package/src/utils/semver-precedence.js +80 -0
- package/src/utils/session-file.js +95 -0
- package/src/utils/site-content-refs.js +23 -0
- package/src/utils/site-copies.js +123 -0
- package/src/utils/site-data-upload.js +302 -0
- package/src/utils/site-identity.js +137 -0
- package/src/utils/snapshot-settings.js +112 -0
- package/src/utils/update-check.js +302 -0
- package/src/utils/uwx-read.js +157 -0
- package/src/utils/workspace.js +223 -0
- package/src/versions.js +467 -0
- package/starter/foundation/main.js +50 -0
- package/starter/foundation/sections/Section/index.jsx +95 -0
- package/starter/foundation/sections/Section/meta.js +44 -0
- package/starter/site/pages/about/1-about.md.hbs +13 -0
- package/starter/site/pages/about/page.yml +3 -0
- package/starter/site/pages/home/1-welcome.md.hbs +14 -0
- package/starter/site/pages/home/page.yml +3 -0
- package/templates/foundation/_gitignore +5 -0
- package/templates/foundation/components/.gitkeep +0 -0
- package/templates/foundation/main.js.hbs +33 -0
- package/templates/foundation/package.json.hbs +51 -0
- package/templates/foundation/sections/.gitkeep +0 -0
- package/templates/foundation/styles.css +6 -0
- package/templates/foundation/vite.config.js +3 -0
- package/templates/site/_gitignore +12 -0
- package/templates/site/entry.js +7 -0
- package/templates/site/index.html.hbs +13 -0
- package/templates/site/package.json.hbs +27 -0
- package/templates/site/public/favicon.svg +7 -0
- package/templates/site/site.yml.hbs +91 -0
- package/templates/site/theme.yml +99 -0
- package/templates/site/vite.config.js +7 -0
- package/templates/workspace/AGENTS.md.hbs +2 -0
- package/templates/workspace/README.md.hbs +87 -0
- package/templates/workspace/_gitignore +29 -0
- package/templates/workspace/_vscode/settings.json +6 -0
- package/templates/workspace/package.json.hbs +20 -0
- 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,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
|
+
}
|