@uniweb/core 0.7.34 → 0.8.0

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,10 +1,11 @@
1
1
  {
2
2
  "name": "@uniweb/core",
3
- "version": "0.7.34",
3
+ "version": "0.8.0",
4
4
  "description": "Core classes for the Uniweb platform - Uniweb, Website, Page, Block",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": "./src/index.js",
8
+ "./data-paths": "./src/data-paths.js",
8
9
  "./fetch-config": "./src/fetch-config.js",
9
10
  "./locale-config": "./src/locale-config.js",
10
11
  "./section-id": "./src/section-id.js"
@@ -33,8 +34,8 @@
33
34
  "vitest": "^4.1.7"
34
35
  },
35
36
  "dependencies": {
36
- "@uniweb/theming": "0.1.15",
37
- "@uniweb/semantic-parser": "1.1.21"
37
+ "@uniweb/semantic-parser": "1.2.0",
38
+ "@uniweb/theming": "0.1.15"
38
39
  },
39
40
  "scripts": {
40
41
  "test": "vitest run"
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Compiled-collection paths — the ONE home for the URL and directory
3
+ * convention that the build emits and every fetcher requests.
4
+ *
5
+ * Why this module exists. The path `/data/<name>.json` was a bare string
6
+ * literal in six places across three packages: the build wrote it
7
+ * (`collection-processor.js`), the build resolved `collection:` to it
8
+ * (`data-fetcher.js`), core injected the per-record default
9
+ * (`fetch-config.js applyDeferredDetail`), core gated locale-prefixing on it
10
+ * (`fetch-config.js localizeConfig`), the dev server matched it with a regex
11
+ * (`build/src/site/plugin.js`), and kit's `useEntityDetail` requested it.
12
+ *
13
+ * They drifted. `useEntityDetail` was edited to `/_data/` on its own and
14
+ * nothing else followed, so a public documented hook requested a URL that
15
+ * nothing anywhere emitted or served — broken on every lane, silently,
16
+ * because it has no call site in this workspace to fail. Emit and request
17
+ * had passing tests the whole time; each pinned its own literal.
18
+ *
19
+ * So the invariant is structural, not documented: producers and consumers
20
+ * read the same constant, and a test asserts they agree. Changing the
21
+ * convention is then one edit here rather than a six-site sweep with a
22
+ * silent-failure trap in it (see `localizeConfig` — a missed site there
23
+ * degrades to default-locale content with a 200, not a 404).
24
+ *
25
+ * WHY THIS PATH IS NOT `_data`. The site's other reserved paths are
26
+ * underscore-prefixed — `_search`, `_pages/`, `_importmap/` — and the
27
+ * inconsistency invites a rename. It has been proposed and declined. Those
28
+ * are machinery: an endpoint and bundler artifacts, which no visitor should
29
+ * land on and which an underscore correctly marks as internal. Compiled
30
+ * collection JSON is the opposite — it is the site's own content, the same
31
+ * records an agent that found the site through `llms.txt` may reasonably
32
+ * fetch directly. `/data/articles.json` is a legitimate public address, and
33
+ * `data` is a legitimate page route; neither collides with the other, since
34
+ * pages emit `.html` and collections emit `.json`. Prefixing it would say
35
+ * "internal" about something that is not.
36
+ *
37
+ * Zero-dependency leaf, like `./locale-config.js`, so a consumer that must
38
+ * not pull core's graph (semantic-parser, theming) can import the subpath
39
+ * `@uniweb/core/data-paths` directly.
40
+ */
41
+
42
+ /**
43
+ * The directory segment, for filesystem joins and regex construction.
44
+ * The build writes `<site>/public/<DATA_DIR>/` and copies it to
45
+ * `<dist>/<DATA_DIR>/`.
46
+ */
47
+ export const DATA_DIR = 'data'
48
+
49
+ /**
50
+ * The URL prefix every compiled-collection request carries. Note the
51
+ * trailing slash: `isDataUrl` is a prefix test, and without it `/database`
52
+ * would match.
53
+ */
54
+ export const DATA_URL_PREFIX = `/${DATA_DIR}/`
55
+
56
+ /**
57
+ * URL of a collection's cascade payload — the whole collection, with
58
+ * `deferred:` fields stripped when the collection declares them.
59
+ *
60
+ * @param {string} name - The collection name.
61
+ * @returns {string} e.g. `/data/articles.json`
62
+ */
63
+ export function collectionDataUrl(name) {
64
+ return `${DATA_URL_PREFIX}${name}.json`
65
+ }
66
+
67
+ /**
68
+ * URL of one record's full payload — every field, including deferred ones.
69
+ * Emitted per item only when the collection declares `deferred:`.
70
+ *
71
+ * Takes either a concrete slug (kit's `useEntityDetail`, which holds a
72
+ * record) or the literal placeholder `{slug}` (core's `applyDeferredDetail`,
73
+ * which builds a pattern that `substitutePlaceholders` resolves later
74
+ * against the dynamic-route param). Both are plain interpolation; this
75
+ * function does not encode, matching the behavior of the call sites it
76
+ * replaced.
77
+ *
78
+ * @param {string} collection - The collection name.
79
+ * @param {string} slug - A record slug, or a `{param}` placeholder.
80
+ * @returns {string} e.g. `/data/articles/design-tips.json`
81
+ */
82
+ export function recordDataUrl(collection, slug) {
83
+ return `${DATA_URL_PREFIX}${collection}/${slug}.json`
84
+ }
85
+
86
+ /**
87
+ * The inverse of `collectionDataUrl` — recover a collection name from a fetch
88
+ * path so a caller can look it up among the declared collections.
89
+ *
90
+ * Best-effort by design, and the caller decides what a miss means: a path
91
+ * outside the compiled-collection tree is returned with only its `.json`
92
+ * suffix removed, which simply will not match any declared collection and
93
+ * lets the caller fall through to reading the file. Nested names round-trip
94
+ * (`/data/archive/2024/posts.json` → `archive/2024/posts`).
95
+ *
96
+ * Lives here because it is the *same* convention read backwards. Left at a
97
+ * call site it becomes a regex with the prefix baked in — which is exactly
98
+ * how `validate-data.js` came to hold a sixth copy of it.
99
+ *
100
+ * @param {string} path - A fetch config's `path`.
101
+ * @returns {string} The derived collection name.
102
+ */
103
+ export function collectionNameFromUrl(path) {
104
+ if (typeof path !== 'string') return ''
105
+ // DATA_DIR is a plain identifier segment, so it needs no regex escaping.
106
+ return path.replace(new RegExp(`^/?${DATA_DIR}/`), '').replace(/\.json$/i, '')
107
+ }
108
+
109
+ /**
110
+ * Whether a fetch config's `path` addresses compiled collection data.
111
+ *
112
+ * Used to scope behavior that only makes sense for build-emitted files —
113
+ * locale prefixing in particular, which must not touch a remote `url:`
114
+ * source or an author-declared `detailUrl:`.
115
+ *
116
+ * @param {*} path - A fetch config's `path` field.
117
+ * @returns {boolean}
118
+ */
119
+ export function isDataUrl(path) {
120
+ return typeof path === 'string' && path.startsWith(DATA_URL_PREFIX)
121
+ }
@@ -12,10 +12,14 @@
12
12
  * carrying a level or a semantic the other lacked). This module is the single
13
13
  * definition they call.
14
14
  *
15
- * INTENTIONALLY A LEAF: no imports, so it is safe to load anywhere — including
16
- * environments with no DOM, no filesystem, and a hard bundle-size ceiling.
17
- * Importing anything here (or from the package root) would defeat that. Keep
18
- * it dependency-free.
15
+ * INTENTIONALLY A LEAF: safe to load anywhere — including environments with no
16
+ * DOM, no filesystem, and a hard bundle-size ceiling. The rule that protects
17
+ * that is **no transitive graph**: import nothing from the package root (which
18
+ * pulls semantic-parser and theming) and nothing that itself imports. A
19
+ * zero-dependency sibling leaf is admissible and `./data-paths.js` is the only
20
+ * one taken — the path convention it holds has to be identical here and in the
21
+ * build that emits the files, and a second copy of that string is precisely
22
+ * the drift this module exists to prevent.
19
23
  *
20
24
  * WHAT THIS DOES NOT OWN: where the sources come from. A caller holding a live
21
25
  * object graph reads them off the graph; a caller holding a content document
@@ -23,6 +27,8 @@
23
27
  * `resolveFetchConfigs`. That difference is real and stays with the caller.
24
28
  */
25
29
 
30
+ import { isDataUrl, recordDataUrl } from './data-paths.js'
31
+
26
32
  /**
27
33
  * Is this fetch declaration a per-instance *refinement* of an ancestor's
28
34
  * config rather than a new source of its own?
@@ -54,7 +60,7 @@ export function isFetchRefinement(cfg) {
54
60
  function localizeConfig(cfg, locale, defaultLocale) {
55
61
  if (!cfg.path) return cfg
56
62
  if (!locale || locale === defaultLocale) return cfg
57
- if (!cfg.path.startsWith('/data/')) return cfg
63
+ if (!isDataUrl(cfg.path)) return cfg
58
64
  return { ...cfg, path: `/${locale}${cfg.path}` }
59
65
  }
60
66
 
@@ -99,7 +105,7 @@ function applyDeferredDetail(cfg, collections) {
99
105
  if (!deferred || deferred.length === 0) return cfg
100
106
  const pattern = typeof collConfig.detailUrl === 'string'
101
107
  ? collConfig.detailUrl
102
- : `/data/${schema}/{slug}.json`
108
+ : recordDataUrl(schema, '{slug}')
103
109
  return { ...cfg, detail: pattern }
104
110
  }
105
111
 
package/src/index.js CHANGED
@@ -27,9 +27,20 @@ export {
27
27
  resolvePublishableLocales,
28
28
  validateLanguageConfig
29
29
  } from './locale-config.js'
30
+ export {
31
+ DATA_DIR,
32
+ DATA_URL_PREFIX,
33
+ collectionDataUrl,
34
+ recordDataUrl,
35
+ collectionNameFromUrl,
36
+ isDataUrl
37
+ } from './data-paths.js'
30
38
  export { evaluate as evaluateWhere, match as matchWhere } from './where.js'
31
- export { isRichSchema } from './schemas.js'
32
- export { resolveStyle as resolveRequestStyle, listStyleNames as listRequestStyleNames } from './request-styles/index.js'
39
+ export { isRichSchema, normalizeSchema } from './schemas.js'
40
+ export {
41
+ resolveStyle as resolveRequestStyle,
42
+ listStyleNames as listRequestStyleNames
43
+ } from './request-styles/index.js'
33
44
 
34
45
  /**
35
46
  * The singleton Uniweb instance.
@@ -53,8 +64,20 @@ export function getUniweb() {
53
64
  * this transport. Used only by the editor's preview iframe.
54
65
  * @returns {Uniweb} The created instance (also assigned to globalThis.uniweb).
55
66
  */
56
- export function createUniweb(content, foundation = null, extensions = [], { defaultFetcher = null, transport = null, dev = false } = {}) {
57
- const instance = new Uniweb({ content, foundation, extensions, defaultFetcher, transport, dev })
67
+ export function createUniweb(
68
+ content,
69
+ foundation = null,
70
+ extensions = [],
71
+ { defaultFetcher = null, transport = null, dev = false } = {}
72
+ ) {
73
+ const instance = new Uniweb({
74
+ content,
75
+ foundation,
76
+ extensions,
77
+ defaultFetcher,
78
+ transport,
79
+ dev
80
+ })
58
81
  globalThis.uniweb = instance
59
82
  return instance
60
83
  }
package/src/schemas.js CHANGED
@@ -35,3 +35,76 @@ export function isRichSchema(schema) {
35
35
  if (schema.childSchema && typeof schema.childSchema === 'object') return true
36
36
  return false
37
37
  }
38
+
39
+ /**
40
+ * Normalize any authored `data:` schema shape to the rich form the editor
41
+ * renders, or null when it is not a single form at all.
42
+ *
43
+ * WHY THIS EXISTS. `isRichSchema` answers "is this already the rich shape?",
44
+ * which is the right question for dispatch and the wrong one for "can this be
45
+ * edited". There are THREE authored shapes and it accepts exactly one:
46
+ *
47
+ * { fields: [ {id, …} ] } meta.js inline rich-form → true
48
+ * { fields: { name: spec } } a RESOLVED NAMED REF → FALSE
49
+ * { name: spec } meta.js inline field map → false
50
+ *
51
+ * The middle row is the important one and the reason this helper is in `core`
52
+ * rather than in the editor. A named ref (`'@/article'`, `'@std/person'`) is the
53
+ * FIRST authoring form the docs show, and `validateAndNormalizeSchema` in the
54
+ * build resolves it to `{ fields: <MAP> }` — a map, not an array. So filtering
55
+ * with `isRichSchema` discards not merely "simple" schemas but the primary
56
+ * documented one, and any consumer that wants to render it has to re-derive the
57
+ * conversion. Three consumers re-deriving it is exactly the divergence the
58
+ * shared predicate was introduced to prevent.
59
+ *
60
+ * A field map is an unordered `fields[]`, so the conversion is mechanical.
61
+ * Ordering comes from `Object.entries`, which is insertion order for string keys
62
+ * — i.e. the order the author wrote, which is the order a form should show.
63
+ *
64
+ * `sections` returns null on purpose: a sectioned data-schema describes a Model
65
+ * with several sections, which is not one form. Flattening it would invent a
66
+ * layout the author never expressed.
67
+ *
68
+ * @param {*} schema - any authored or resolved `data:` schema value
69
+ * @returns {{ fields: Array<object> } | null} the rich shape, or null
70
+ */
71
+ export function normalizeSchema(schema) {
72
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema))
73
+ return null
74
+
75
+ // Already rich — hand back untouched. Composite/childSchema variants are rich
76
+ // by `isRichSchema`'s definition and are not ours to reshape.
77
+ if (Array.isArray(schema.fields)) return schema
78
+ if (schema.isComposite === true || schema.childSchema) return schema
79
+
80
+ // A sectioned Model is not a single form.
81
+ if (schema.sections !== undefined) return null
82
+
83
+ const mapToFields = (map) =>
84
+ Object.entries(map).map(([id, spec]) =>
85
+ typeof spec === 'string' ? { id, type: spec } : { id, ...spec }
86
+ )
87
+
88
+ // A resolved named ref: `fields` present, as a map.
89
+ if (schema.fields && typeof schema.fields === 'object') {
90
+ const { fields, ...rest } = schema
91
+ return { ...rest, fields: mapToFields(fields) }
92
+ }
93
+
94
+ // An inline field map: no `fields` key, so every value must be an OBJECT
95
+ // carrying `type`.
96
+ //
97
+ // The bare-type string shorthand (`{ cpu: 'string' }`) is deliberately NOT
98
+ // accepted here, even though schema FILES support it. Without a `fields` key
99
+ // there is nothing to distinguish it from ordinary data: `{ name: 'Acme' }` and
100
+ // `{ cpu: 'string' }` are the same shape, and an earlier cut of this function
101
+ // turned `{ name, description }` into a two-field form. Erring toward null
102
+ // costs an author the object spelling; erring the other way invents a form out
103
+ // of a config block.
104
+ const entries = Object.entries(schema)
105
+ if (!entries.length) return null
106
+ const isFieldSpec = ([, v]) =>
107
+ v && typeof v === 'object' && !Array.isArray(v) && v.type !== undefined
108
+ if (!entries.every(isFieldSpec)) return null
109
+ return { fields: mapToFields(schema) }
110
+ }