uniweb 0.9.2 → 0.9.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "uniweb",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "description": "Create structured Vite + React sites with content/code separation",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,14 +41,14 @@
41
41
  "js-yaml": "^4.1.0",
42
42
  "prompts": "^2.4.2",
43
43
  "tar": "^7.0.0",
44
- "@uniweb/runtime": "0.7.2",
45
44
  "@uniweb/core": "0.6.1",
46
- "@uniweb/kit": "0.8.1"
45
+ "@uniweb/kit": "0.8.1",
46
+ "@uniweb/runtime": "0.7.3"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "@uniweb/content-reader": "1.1.4",
50
- "@uniweb/build": "0.9.2",
51
- "@uniweb/semantic-parser": "1.1.9"
50
+ "@uniweb/semantic-parser": "1.1.9",
51
+ "@uniweb/build": "0.9.3"
52
52
  },
53
53
  "peerDependenciesMeta": {
54
54
  "@uniweb/build": {
@@ -0,0 +1,203 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "generatedAt": "2026-04-15T22:47:02.926Z",
4
+ "packages": {
5
+ "@uniweb/build": {
6
+ "version": "0.9.3",
7
+ "path": "framework/build",
8
+ "deps": [
9
+ "@uniweb/content-reader",
10
+ "@uniweb/core",
11
+ "@uniweb/runtime",
12
+ "@uniweb/schemas",
13
+ "@uniweb/theming"
14
+ ]
15
+ },
16
+ "@uniweb/content-reader": {
17
+ "version": "1.1.4",
18
+ "path": "framework/content-reader",
19
+ "deps": []
20
+ },
21
+ "@uniweb/content-writer": {
22
+ "version": "0.2.3",
23
+ "path": "framework/content-writer",
24
+ "deps": []
25
+ },
26
+ "@uniweb/core": {
27
+ "version": "0.6.1",
28
+ "path": "framework/core",
29
+ "deps": [
30
+ "@uniweb/semantic-parser",
31
+ "@uniweb/theming"
32
+ ]
33
+ },
34
+ "@uniweb/frame-bridge": {
35
+ "version": "0.2.3",
36
+ "path": "framework/frame-bridge",
37
+ "deps": []
38
+ },
39
+ "@uniweb/icons": {
40
+ "version": "0.1.0",
41
+ "path": "framework/icons",
42
+ "deps": []
43
+ },
44
+ "@uniweb/kit": {
45
+ "version": "0.8.1",
46
+ "path": "framework/kit",
47
+ "deps": [
48
+ "@uniweb/core"
49
+ ]
50
+ },
51
+ "@uniweb/loom": {
52
+ "version": "0.2.1",
53
+ "path": "framework/loom",
54
+ "deps": []
55
+ },
56
+ "@uniweb/press": {
57
+ "version": "0.2.1",
58
+ "path": "framework/press",
59
+ "deps": []
60
+ },
61
+ "@uniweb/runtime": {
62
+ "version": "0.7.3",
63
+ "path": "framework/runtime",
64
+ "deps": [
65
+ "@uniweb/core",
66
+ "@uniweb/theming"
67
+ ]
68
+ },
69
+ "@uniweb/schemas": {
70
+ "version": "0.2.1",
71
+ "path": "framework/schemas",
72
+ "deps": []
73
+ },
74
+ "@uniweb/scholar": {
75
+ "version": "0.2.1",
76
+ "path": "framework/scholar",
77
+ "deps": []
78
+ },
79
+ "@uniweb/semantic-parser": {
80
+ "version": "1.1.9",
81
+ "path": "framework/semantic-parser",
82
+ "deps": []
83
+ },
84
+ "@uniweb/templates": {
85
+ "version": "0.7.23",
86
+ "path": "framework/templates",
87
+ "deps": []
88
+ },
89
+ "@uniweb/theming": {
90
+ "version": "0.1.3",
91
+ "path": "framework/theming",
92
+ "deps": []
93
+ }
94
+ },
95
+ "templates": {
96
+ "marketing": {
97
+ "name": "Marketing Starter",
98
+ "description": "Landing pages and marketing sites with Tailwind CSS v4",
99
+ "tags": [
100
+ "marketing",
101
+ "landing-page",
102
+ "tailwind"
103
+ ]
104
+ },
105
+ "academic": {
106
+ "name": "Academic",
107
+ "description": "Academic sites for researchers, labs, and departments",
108
+ "tags": [
109
+ "academic",
110
+ "research",
111
+ "portfolio"
112
+ ]
113
+ },
114
+ "docs": {
115
+ "name": "Documentation",
116
+ "description": "Documentation sites with navigation levels",
117
+ "tags": [
118
+ "docs",
119
+ "documentation",
120
+ "technical"
121
+ ]
122
+ },
123
+ "international": {
124
+ "name": "International Business",
125
+ "description": "Multilingual corporate website with English, Spanish, and French",
126
+ "tags": [
127
+ "i18n",
128
+ "multilingual",
129
+ "corporate",
130
+ "business",
131
+ "localization"
132
+ ]
133
+ },
134
+ "dynamic": {
135
+ "name": "Dynamic Data",
136
+ "description": "Live API data fetching with loading states, transforms, and the portable data pattern",
137
+ "tags": [
138
+ "data",
139
+ "api",
140
+ "fetch",
141
+ "loading-states",
142
+ "dynamic"
143
+ ]
144
+ },
145
+ "store": {
146
+ "name": "Solis Artisans - Store",
147
+ "description": "Artisan e-commerce with product collections, Shopify Buy Button, journal blog, and stone-amber design",
148
+ "tags": [
149
+ "store",
150
+ "e-commerce",
151
+ "shopify",
152
+ "artisan",
153
+ "products"
154
+ ]
155
+ },
156
+ "learning": {
157
+ "name": "Learning Platform",
158
+ "description": "Course-based learning site with quizzes, code challenges, and AI grading integration",
159
+ "tags": [
160
+ "learning",
161
+ "course",
162
+ "education",
163
+ "quiz",
164
+ "lms",
165
+ "tutorial"
166
+ ]
167
+ },
168
+ "extensions": {
169
+ "name": "Extensions Demo",
170
+ "description": "Primary foundation with a visual effects extension, demonstrating multi-foundation sites",
171
+ "tags": [
172
+ "extensions",
173
+ "multi-foundation",
174
+ "visual-effects",
175
+ "runtime"
176
+ ]
177
+ },
178
+ "cv": {
179
+ "name": "CV (Docusite)",
180
+ "description": "An academic CV as a docusite — a URL whose content is also a downloadable Microsoft Word document. Ships with a full Charles Darwin sample CV and demonstrates the one-foundation-many-tenants pattern for download-time theming.",
181
+ "tags": [
182
+ "cv",
183
+ "resume",
184
+ "docusite",
185
+ "academic",
186
+ "document",
187
+ "press"
188
+ ]
189
+ },
190
+ "cv-loom": {
191
+ "name": "CV (Loom-instantiated)",
192
+ "description": "A single-page narrative CV whose prose is written with {placeholder} expressions and instantiated at render time via the foundation content handler and @uniweb/loom. Reference implementation of the handler hook.",
193
+ "tags": [
194
+ "cv",
195
+ "loom",
196
+ "template-engine",
197
+ "content-handler",
198
+ "dynamic-prose",
199
+ "academic"
200
+ ]
201
+ }
202
+ }
203
+ }
@@ -2,11 +2,81 @@
2
2
  * Template resolver - parses template identifiers and determines source type
3
3
  */
4
4
 
5
+ import { readFileSync, statSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url))
10
+
5
11
  // Built-in templates (programmatic, not file-based)
6
12
  export const BUILTIN_TEMPLATES = ['blank', 'starter', 'none']
7
13
 
8
- // Official templates from @uniweb/templates package (downloaded from GitHub releases)
9
- export const OFFICIAL_TEMPLATES = ['marketing', 'academic', 'docs', 'international', 'dynamic', 'store', 'learning', 'extensions', 'cv']
14
+ /**
15
+ * Load the list of official template names.
16
+ *
17
+ * There are two sources of truth depending on where the CLI is running:
18
+ *
19
+ * 1. **Local dev inside the Uniweb monorepo** — the authoritative file
20
+ * is `framework/templates/manifest.json`. Adding a new template
21
+ * there makes it immediately reachable from any locally-run CLI
22
+ * without republishing. This is the only path that matters for
23
+ * `node scripts/framework/sandbox.js create`.
24
+ *
25
+ * 2. **Published CLI (npm-installed)** — the monorepo isn't on disk, so
26
+ * we read the vendored framework index at `../framework-index.json`,
27
+ * which the publish pipeline's pre-publish hook rewrites just before
28
+ * `pnpm publish` runs. The framework index is a single snapshot file
29
+ * that also carries `@uniweb/*` package versions (consumed by
30
+ * versions.js), so both the template list and the version resolver
31
+ * share one source of truth.
32
+ *
33
+ * When both sources are available (local dev with a committed snapshot),
34
+ * the live workspace manifest wins so newly-added templates are visible
35
+ * without waiting for a CLI republish.
36
+ *
37
+ * The previous implementation hardcoded a string array here, which
38
+ * silently duplicated `framework/templates/manifest.json` and required
39
+ * a two-repo edit whenever a template was added. The one before THAT
40
+ * (v0.9.3) used a separate `./official-templates.snapshot.json` file;
41
+ * it has been retired in favor of the shared framework index.
42
+ */
43
+ function loadOfficialTemplateList() {
44
+ // Local dev: framework/templates/manifest.json relative to this file
45
+ // at framework/cli/src/templates/resolver.js
46
+ const workspaceManifest = join(__dirname, '..', '..', '..', 'templates', 'manifest.json')
47
+ const picked = tryReadTemplateKeys(workspaceManifest)
48
+ if (picked) return picked
49
+
50
+ // Published CLI fallback: the framework index snapshot, one directory
51
+ // up at framework/cli/src/framework-index.json.
52
+ const indexPath = join(__dirname, '..', 'framework-index.json')
53
+ const fromIndex = tryReadTemplateKeys(indexPath)
54
+ if (fromIndex) return fromIndex
55
+
56
+ // If both sources fail, return an empty list rather than a stale
57
+ // hardcoded array. An unknown template name then falls through to
58
+ // the npm `@uniweb/template-<name>` lookup path, which is the
59
+ // intended behavior for third-party templates.
60
+ return []
61
+ }
62
+
63
+ function tryReadTemplateKeys(path) {
64
+ try {
65
+ if (!statSync(path).isFile()) return null
66
+ const data = JSON.parse(readFileSync(path, 'utf8'))
67
+ if (data && data.templates && typeof data.templates === 'object') {
68
+ return Object.keys(data.templates)
69
+ }
70
+ } catch {}
71
+ return null
72
+ }
73
+
74
+ // Official templates from the templates repo. Derived from manifest.json
75
+ // (local dev) or framework-index.json (published CLI) at module load
76
+ // time — see loadOfficialTemplateList() for details. If the list needs
77
+ // to reflect a just-added template, restart the CLI process or rerun
78
+ // the scaffolder; this constant is read once per process.
79
+ export const OFFICIAL_TEMPLATES = loadOfficialTemplateList()
10
80
 
11
81
  /**
12
82
  * Parse a template identifier and determine its source type
@@ -10,6 +10,7 @@ import { existsSync, readdirSync } from 'node:fs'
10
10
  import { join, dirname } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import yaml from 'js-yaml'
13
+ import Handlebars from 'handlebars'
13
14
  import { copyTemplateDirectory, registerVersions } from '../templates/processor.js'
14
15
  import { getVersionsForTemplates, getCliVersion } from '../versions.js'
15
16
 
@@ -157,19 +158,38 @@ async function copyContentRecursive(sourceDir, targetDir, context, structuralFil
157
158
  newContent = template(context)
158
159
  }
159
160
 
160
- // Merge config files instead of overwriting
161
+ // Merge config files instead of overwriting.
162
+ //
163
+ // The "merge" here is narrow: take the content template's output
164
+ // as the source of truth (so comments, formatting, and the full
165
+ // educational structure of the content template survive) and
166
+ // override only the specific top-level keys listed in
167
+ // preserveKeys with the values from the already-scaffolded base
168
+ // file (so the user's chosen project name and foundation ref
169
+ // don't get replaced by whatever the content template hardcoded).
170
+ //
171
+ // Earlier versions of this code parsed both files through
172
+ // js-yaml, merged the objects, and re-emitted the result via
173
+ // yaml.dump() — which stripped every comment and blank line on
174
+ // the way through. For templates like `cv/site/site.yml.hbs`
175
+ // whose comments are the template's educational payload, that
176
+ // was devastating. The line-level override below preserves the
177
+ // content template's text verbatim except for the preserved keys.
161
178
  const preserveKeys = mergeFiles[outputName]
162
179
  if (preserveKeys && existsSync(targetPath)) {
163
180
  const existingContent = await fs.readFile(targetPath, 'utf-8')
164
181
  const existing = yaml.load(existingContent) || {}
165
- const incoming = yaml.load(newContent || await fs.readFile(sourcePath, 'utf-8')) || {}
182
+ let merged = newContent ?? await fs.readFile(sourcePath, 'utf-8')
166
183
 
167
- // Template values as base, preserve specified keys from scaffolded version
168
- const merged = { ...incoming }
169
184
  for (const key of preserveKeys) {
170
- if (existing[key] !== undefined) merged[key] = existing[key]
185
+ if (existing[key] === undefined) continue
186
+ const baseLine = matchTopLevelLine(existingContent, key)
187
+ if (baseLine) {
188
+ merged = replaceTopLevelLine(merged, key, baseLine)
189
+ }
171
190
  }
172
- await fs.writeFile(targetPath, yaml.dump(merged, { lineWidth: -1 }))
191
+
192
+ await fs.writeFile(targetPath, merged)
173
193
  } else if (newContent !== undefined) {
174
194
  await fs.writeFile(targetPath, newContent)
175
195
  } else {
@@ -208,14 +228,76 @@ export async function applyStarter(projectDir, context, options = {}) {
208
228
  await applyContent(siteContentDir, siteTargetDir, context, options)
209
229
  }
210
230
 
231
+ /**
232
+ * Escape a string for safe inclusion in a RegExp literal. Used by the
233
+ * site.yml line-level merge path.
234
+ */
235
+ function escapeRegex(s) {
236
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
237
+ }
238
+
239
+ /**
240
+ * Find the verbatim text of a single-line top-level YAML entry like
241
+ * `name: foo bar` or `foundation: my-foundation`. Returns the matched
242
+ * line (including any inline comment) or null if the key isn't present.
243
+ * Multi-line values (block scalars, nested maps) are deliberately
244
+ * unsupported — the merge path only uses this for simple scalar keys.
245
+ */
246
+ function matchTopLevelLine(content, key) {
247
+ const match = content.match(new RegExp(`^${escapeRegex(key)}:.*$`, 'm'))
248
+ return match ? match[0] : null
249
+ }
250
+
251
+ /**
252
+ * Replace the verbatim text of a single-line top-level YAML entry.
253
+ * Returns the content unchanged if the key isn't present. See
254
+ * matchTopLevelLine for scope.
255
+ */
256
+ function replaceTopLevelLine(content, key, replacement) {
257
+ return content.replace(
258
+ new RegExp(`^${escapeRegex(key)}:.*$`, 'm'),
259
+ () => replacement,
260
+ )
261
+ }
262
+
263
+ /**
264
+ * Resolve a dependency version string from a template.json entry.
265
+ *
266
+ * Template authors can either hardcode a concrete spec (`"^0.2.1"`) or
267
+ * use the same `{{version}}` Handlebars helper that `package.json.hbs`
268
+ * uses (`"{{version \"@uniweb/press\"}}"`). The helper is populated
269
+ * earlier in the scaffold flow via `registerVersions()`, so by the time
270
+ * this runs `versionData` already holds the current on-disk versions
271
+ * of every `@uniweb/*` package.
272
+ *
273
+ * Plain strings without `{{…}}` pass through untouched. Strings that
274
+ * fail to compile (rare — e.g. malformed mustache) fall back to the
275
+ * original literal so a bad template.json doesn't break scaffolding.
276
+ */
277
+ function resolveDependencyVersion(rawValue) {
278
+ if (typeof rawValue !== 'string' || !rawValue.includes('{{')) {
279
+ return rawValue
280
+ }
281
+ try {
282
+ return Handlebars.compile(rawValue)({})
283
+ } catch {
284
+ return rawValue
285
+ }
286
+ }
287
+
211
288
  /**
212
289
  * Merge additional dependencies from a content template into a scaffolded package.json
213
290
  *
214
291
  * Reads the package.json at the given path, adds any deps not already present
215
292
  * (in either dependencies or devDependencies), and writes it back.
216
293
  *
294
+ * Each version string in `deps` is first processed through the shared
295
+ * Handlebars pipeline so template.json entries can reference live
296
+ * workspace versions via `{{version "@uniweb/press"}}` instead of
297
+ * hardcoding a spec that goes stale on the next publish.
298
+ *
217
299
  * @param {string} packageJsonPath - Absolute path to package.json
218
- * @param {Object} deps - Dependencies to merge (name → version)
300
+ * @param {Object} deps - Dependencies to merge (name → version spec)
219
301
  */
220
302
  export async function mergeTemplateDependencies(packageJsonPath, deps) {
221
303
  if (!deps || Object.keys(deps).length === 0) return
@@ -223,7 +305,7 @@ export async function mergeTemplateDependencies(packageJsonPath, deps) {
223
305
  if (!pkg.dependencies) pkg.dependencies = {}
224
306
  for (const [name, version] of Object.entries(deps)) {
225
307
  if (!pkg.dependencies[name] && !pkg.devDependencies?.[name]) {
226
- pkg.dependencies[name] = version
308
+ pkg.dependencies[name] = resolveDependencyVersion(version)
227
309
  }
228
310
  }
229
311
  await fs.writeFile(packageJsonPath, JSON.stringify(pkg, null, 2) + '\n')
package/src/versions.js CHANGED
@@ -96,12 +96,61 @@ function readWorkspaceVersion(packageName) {
96
96
  return null
97
97
  }
98
98
 
99
+ /**
100
+ * Load the vendored framework index file (see
101
+ * `./framework-index.json`). The index is a snapshot of the framework
102
+ * state taken at CLI publish time — every `@uniweb/*` package name,
103
+ * version, path, and inter-package dep edges, plus the template list.
104
+ *
105
+ * The snapshot is the only way a published CLI (running from
106
+ * `node_modules/uniweb/…`, with no workspace on disk) can resolve
107
+ * versions for packages it doesn't directly import (press, loom,
108
+ * scholar, etc.). Without it, Handlebars helpers like
109
+ * `{{version "@uniweb/press"}}` fall through to a bogus default.
110
+ *
111
+ * Returns null if the file doesn't exist, is unparseable, or has a
112
+ * schema version we don't understand. Callers treat null as "no
113
+ * snapshot available" and fall back to their next source.
114
+ */
115
+ function loadFrameworkIndex() {
116
+ const indexPath = join(__dirname, 'framework-index.json')
117
+ try {
118
+ const raw = readFileSync(indexPath, 'utf8')
119
+ const parsed = JSON.parse(raw)
120
+ if (parsed && parsed.schemaVersion === 1 && parsed.packages) {
121
+ return parsed
122
+ }
123
+ } catch {}
124
+ return null
125
+ }
126
+
127
+ /**
128
+ * Pull `@uniweb/*` package versions out of the framework index's
129
+ * `packages` field and format them as caret ranges. Used as the
130
+ * published-CLI fallback source after the CLI's own deps and the live
131
+ * workspace walk both come up empty.
132
+ */
133
+ function readIndexPackages() {
134
+ const index = loadFrameworkIndex()
135
+ if (!index) return {}
136
+ const result = {}
137
+ for (const [name, entry] of Object.entries(index.packages)) {
138
+ if (entry && entry.version) {
139
+ result[name] = `^${entry.version}`
140
+ }
141
+ }
142
+ return result
143
+ }
144
+
99
145
  /**
100
146
  * Enumerate every `@uniweb/*` package under `framework/*` and return a
101
147
  * map of `{ name: '^version' }`. Used to seed the resolved-versions
102
148
  * cache so that packages not explicitly listed in the CLI's own deps
103
149
  * (press, loom, scholar, schemas, etc.) still have a version available
104
150
  * to templates that reference them.
151
+ *
152
+ * Works only in the monorepo. Published CLIs fall through to the
153
+ * framework-index snapshot via readIndexPackages() instead.
105
154
  */
106
155
  function discoverWorkspacePackages() {
107
156
  const root = getFrameworkRoot()
@@ -145,17 +194,23 @@ function resolveVersionSpec(spec, packageName) {
145
194
  * Get resolved versions for @uniweb/* packages.
146
195
  *
147
196
  * Priority (highest first):
197
+ *
148
198
  * 1. A concrete version spec already in the CLI's own `package.json`
149
- * (the state after an npm publish: `workspace:*` is resolved to a
150
- * real version).
151
- * 2. The on-disk version of the matching workspace package (the state
152
- * during local development).
153
- * 3. Discovery fallback every `@uniweb/*` package found under
154
- * `framework/*`, for packages that aren't in the CLI's own deps.
199
+ * (the state after an npm publish: `workspace:*` is resolved by
200
+ * pnpm to a real version).
201
+ * 2. Live workspace walk every `@uniweb/*` package found under
202
+ * `framework/*` at CLI invocation time. This is the path that
203
+ * matters for local dev: a freshly-added package becomes
204
+ * reachable from every locally-run CLI without republishing.
205
+ * 3. Framework index snapshot — `./framework-index.json`, written by
206
+ * the publish pipeline's pre-publish hook. This is the path that
207
+ * matters for published CLIs: the monorepo isn't on disk, so the
208
+ * snapshot is how the CLI knows about packages it doesn't import
209
+ * directly (press, loom, scholar, schemas, …).
155
210
  *
156
- * The return shape is stable across both paths: a map of package names to
157
- * npm-compatible version specs, plus the CLI's own version under the key
158
- * `uniweb`.
211
+ * The return shape is stable across all paths: a map of package names
212
+ * to npm-compatible version specs, plus the CLI's own version under
213
+ * the key `uniweb`.
159
214
  *
160
215
  * @returns {Object} Map of package names to version specs
161
216
  */
@@ -173,14 +228,24 @@ export function getResolvedVersions() {
173
228
  if (resolved) result[name] = resolved
174
229
  }
175
230
 
176
- // Merge in anything under framework/* that the CLI didn't list explicitly.
177
- // A newly-added package (e.g. @uniweb/press) becomes reachable via
178
- // {{version "@uniweb/press"}} without touching the CLI's package.json.
231
+ // Layer in the live workspace walk. Overrides nothing (dev versions
232
+ // are fresher than anything in the CLI's own deps), but fills in
233
+ // packages the CLI doesn't reference directly.
179
234
  const discovered = discoverWorkspacePackages()
180
235
  for (const [name, version] of Object.entries(discovered)) {
181
236
  if (!result[name]) result[name] = version
182
237
  }
183
238
 
239
+ // Final fallback: the vendored framework index snapshot. Only hits
240
+ // when the workspace walk came up empty for a package (published
241
+ // CLI running outside the monorepo). The snapshot is refreshed at
242
+ // publish time, so its view of the world is current as of the CLI's
243
+ // own publish.
244
+ const indexed = readIndexPackages()
245
+ for (const [name, version] of Object.entries(indexed)) {
246
+ if (!result[name]) result[name] = version
247
+ }
248
+
184
249
  // CLI itself. Caret on the current version — templates referencing
185
250
  // `{{version "uniweb"}}` pick up whatever patch/minor ships in the
186
251
  // same publish cycle as the template.