@ossy/app 1.16.0 → 1.16.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/cli/build.task.js CHANGED
@@ -46,11 +46,21 @@ function ensureDir (dir) {
46
46
  fs.mkdirSync(dir, { recursive: true })
47
47
  }
48
48
 
49
- function stubFileNameFor (kind, sourcePath, srcDir) {
49
+ // One source of truth for the per-entry name. Used both as the Rollup
50
+ // `input` key (which becomes the `[name]` token in `entryFileNames`) and
51
+ // as the basename of the generated stub. Flattening the full relative
52
+ // path with `__` keeps two files with the same basename in different
53
+ // folders (e.g. `apps/home.page.jsx` and `analytics/home.page.jsx`) from
54
+ // colliding on the Rollup input map — which the filesystem already
55
+ // guarantees can't happen at the relative-path level.
56
+ function entryNameFromSource (sourcePath, srcDir) {
50
57
  const rel = path.relative(srcDir, sourcePath).replace(/\\/g, '/')
51
- const flat = rel.replace(/\//g, '__')
58
+ return rel.replace(/\//g, '__').replace(/\.(jsx?|tsx?|mjs|cjs)$/, '')
59
+ }
60
+
61
+ function stubFileNameFor (kind, sourcePath, srcDir) {
52
62
  const ext = kind === 'page' ? '.entry.jsx' : '.entry.js'
53
- return flat.replace(/\.(jsx?|tsx?|mjs|cjs)$/, '') + ext
63
+ return entryNameFromSource(sourcePath, srcDir) + ext
54
64
  }
55
65
 
56
66
  function relImport (fromAbs, toAbs) {
@@ -147,17 +157,12 @@ export async function build (cliArgs = []) {
147
157
  const stubInputMap = {}
148
158
 
149
159
  for (const entry of allEntries) {
150
- const { kind, sourcePath, metadata } = entry
151
- const stubFileName = stubFileNameFor(kind, sourcePath, srcDir)
152
- const stubAbs = path.join(stubDir, stubFileName)
160
+ const { kind, sourcePath } = entry
161
+ const inputName = entryNameFromSource(sourcePath, srcDir)
162
+ const stubAbs = path.join(stubDir, stubFileNameFor(kind, sourcePath, srcDir))
153
163
  fs.writeFileSync(stubAbs, stubFor(kind, { stubAbs, sourceAbs: sourcePath }), 'utf8')
154
-
155
- const inputName = path.basename(sourcePath).replace(/\.(jsx?|tsx?|mjs|cjs)$/, '')
156
- if (stubInputMap[inputName]) {
157
- throw new Error(`[@ossy/app][build] Duplicate entry name "${inputName}" between ${stubInputMap[inputName]} and ${stubAbs}.`)
158
- }
159
164
  stubInputMap[inputName] = stubAbs
160
- entriesByStub.set(stubAbs, { kind, sourcePath, metadata })
165
+ entriesByStub.set(stubAbs, { kind, sourcePath })
161
166
  }
162
167
 
163
168
  const configPath = path.resolve(srcDir, 'config.js')
@@ -197,6 +202,8 @@ export async function build (cliArgs = []) {
197
202
  }),
198
203
  manifestPlugin({
199
204
  entriesByStub,
205
+ srcDir,
206
+ staticOutDir,
200
207
  configValue,
201
208
  manifestPath: path.join(buildPath, 'manifest.json'),
202
209
  }),
@@ -1,6 +1,5 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
- import { parse as babelParse } from '@babel/parser'
4
3
 
5
4
  export const PAGE_FILE_PATTERN = /\.page\.(jsx?|tsx?)$/
6
5
  export const API_FILE_PATTERN = /\.api\.(mjs|cjs|js)$/
@@ -10,9 +9,8 @@ export const TASK_FILE_PATTERN = /\.task\.(mjs|cjs|js)$/
10
9
  * @typedef {'page' | 'api' | 'task'} EntryKind
11
10
  *
12
11
  * @typedef {object} PlatformEntry
13
- * @property {EntryKind} kind Which bucket this entry lives in.
14
- * @property {string} sourcePath Absolute path to the user-authored source file.
15
- * @property {object} metadata Normalized metadata (always has `id`; pages always have `path`).
12
+ * @property {EntryKind} kind Which bucket this entry lives in.
13
+ * @property {string} sourcePath Absolute path to the user-authored source file.
16
14
  *
17
15
  * @typedef {object} PlatformFiles
18
16
  * @property {PlatformEntry[]} pages Discovered `*.page.{jsx,tsx,...}` entries.
@@ -43,7 +41,14 @@ function classifyFile (absPath) {
43
41
  return null
44
42
  }
45
43
 
46
- function metadataIdFromFile (absPath, srcDir) {
44
+ /**
45
+ * Default `id` derived from a source file's location relative to `src/`.
46
+ * Used when a source module doesn't export `metadata.id`.
47
+ * `src/home.page.jsx` -> `home`
48
+ * `src/index.page.jsx` -> `home`
49
+ * `src/blog/post.page.jsx` -> `blog-post`
50
+ */
51
+ export function metadataIdFromFile (absPath, srcDir) {
47
52
  const rel = path.relative(srcDir, absPath).replace(/\\/g, '/')
48
53
  const noExt = rel
49
54
  .replace(PAGE_FILE_PATTERN, '')
@@ -53,110 +58,22 @@ function metadataIdFromFile (absPath, srcDir) {
53
58
  return noExt.replace(/\//g, '-')
54
59
  }
55
60
 
56
- function defaultPageRoute (id) {
61
+ /**
62
+ * Default URL path for a page when `metadata.path` isn't set.
63
+ * `home` is a special case — it lives at `/`.
64
+ */
65
+ export function defaultPageRoute (id) {
57
66
  return id === 'home' ? '/' : '/' + id
58
67
  }
59
68
 
60
- // Parses the static `metadata` export from a source file without executing it.
61
- // Returns a plain JS-able shape: strings, numbers, booleans, arrays, and
62
- // nested objects with string keys. Arbitrary expressions (function calls,
63
- // references) are not supported here — pages must declare their metadata as
64
- // a literal object expression so the build can read it without bundling.
65
- function parseMetadataFromSource (absPath) {
66
- const source = fs.readFileSync(absPath, 'utf8')
67
- const ext = path.extname(absPath)
68
- const plugins = ['importMeta']
69
- if (ext === '.tsx' || ext === '.ts') plugins.push('typescript')
70
- if (ext === '.jsx' || ext === '.tsx') plugins.push('jsx')
71
-
72
- let ast
73
- try {
74
- ast = babelParse(source, {
75
- sourceType: 'module',
76
- allowImportExportEverywhere: true,
77
- plugins,
78
- })
79
- } catch (err) {
80
- throw new Error(`[@ossy/app][build] Failed to parse ${absPath}: ${err.message}`)
81
- }
82
-
83
- for (const node of ast.program.body) {
84
- if (node.type !== 'ExportNamedDeclaration') continue
85
- const decl = node.declaration
86
- if (!decl || decl.type !== 'VariableDeclaration') continue
87
- for (const declarator of decl.declarations) {
88
- if (declarator.id?.type !== 'Identifier') continue
89
- if (declarator.id.name !== 'metadata') continue
90
- const literal = literalFromNode(declarator.init)
91
- if (literal === undefined) {
92
- throw new Error(`[@ossy/app][build] metadata in ${absPath} must be a literal object expression.`)
93
- }
94
- return literal
95
- }
96
- }
97
- return null
98
- }
99
-
100
- function literalFromNode (node) {
101
- if (!node) return undefined
102
- switch (node.type) {
103
- case 'StringLiteral':
104
- case 'NumericLiteral':
105
- case 'BooleanLiteral':
106
- return node.value
107
- case 'NullLiteral':
108
- return null
109
- case 'TemplateLiteral':
110
- if (node.expressions.length === 0) return node.quasis.map((q) => q.value.cooked).join('')
111
- return undefined
112
- case 'ArrayExpression': {
113
- const out = []
114
- for (const el of node.elements) {
115
- if (el === null) { out.push(null); continue }
116
- const value = literalFromNode(el)
117
- if (value === undefined) return undefined
118
- out.push(value)
119
- }
120
- return out
121
- }
122
- case 'ObjectExpression': {
123
- const out = {}
124
- for (const prop of node.properties) {
125
- if (prop.type !== 'ObjectProperty') return undefined
126
- let key
127
- if (prop.key.type === 'Identifier') key = prop.key.name
128
- else if (prop.key.type === 'StringLiteral') key = prop.key.value
129
- else return undefined
130
- const value = literalFromNode(prop.value)
131
- if (value === undefined) return undefined
132
- out[key] = value
133
- }
134
- return out
135
- }
136
- default:
137
- return undefined
138
- }
139
- }
140
-
141
- function ensureMetadataForKind ({ kind, sourcePath, parsed, srcDir }) {
142
- const fallbackId = metadataIdFromFile(sourcePath, srcDir)
143
- const meta = { ...(parsed || {}) }
144
- if (!meta.id) meta.id = fallbackId
145
- if (kind === 'page' && meta.path === undefined) meta.path = defaultPageRoute(meta.id)
146
- if (kind === 'task' && !meta.id && meta.type) meta.id = meta.type
147
- return meta
148
- }
149
-
150
69
  /**
151
- * Walks `srcDir` and returns the user-authored entries that the platform
152
- * cares about, bucketed by kind and with their static metadata already
153
- * parsed. Empty (zero-byte) source files are skipped — they're treated as
154
- * placeholders the user is mid-creating.
70
+ * Walks `srcDir` and returns the user-authored entries the platform cares
71
+ * about, bucketed by kind. Empty (zero-byte) source files are skipped — we
72
+ * treat them as placeholders the user is mid-creating.
155
73
  *
156
- * Build is the only thing that calls this today, but it's deliberately a
157
- * separate task so the gather phase stays inspectable on its own (handy
158
- * for tooling, dev servers, and tests that want to know "what's in src/?"
159
- * without triggering Rollup).
74
+ * Metadata isn't read here on purpose: the build pipeline reads it from the
75
+ * bundled output instead, which lets users compute `metadata.path` (and
76
+ * anything else) from imports, config, env vars, etc.
160
77
  *
161
78
  * @param {string} srcDir Absolute path to the project's `src/` directory.
162
79
  * @returns {Promise<PlatformFiles>}
@@ -173,12 +90,9 @@ export default async function getPlatformFiles (srcDir) {
173
90
  if (!stat.isFile() || stat.size === 0) continue
174
91
  const kind = classifyFile(sourcePath)
175
92
  if (!kind) continue
176
- const parsed = parseMetadataFromSource(sourcePath)
177
- const metadata = ensureMetadataForKind({ kind, sourcePath, parsed, srcDir })
178
93
  out[kind === 'page' ? 'pages' : kind === 'api' ? 'apis' : 'tasks'].push({
179
94
  kind,
180
95
  sourcePath,
181
- metadata,
182
96
  })
183
97
  }
184
98
  return out
@@ -1,5 +1,8 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ import { metadataIdFromFile, defaultPageRoute } from './get-platform-files.task.js'
3
6
 
4
7
  /**
5
8
  * @typedef {import('./get-platform-files.task.js').PlatformEntry} PlatformEntry
@@ -7,9 +10,16 @@ import path from 'node:path'
7
10
  * @typedef {object} ManifestPluginOptions
8
11
  * @property {Map<string, PlatformEntry>} entriesByStub
9
12
  * Maps each generated stub's absolute path (Rollup's `facadeModuleId`) to
10
- * the `PlatformEntry` it was created for. The plugin uses this to recover
11
- * the entry's `kind` and parsed metadata after Rollup has bundled and
12
- * hashed each chunk.
13
+ * the `PlatformEntry` it was created for. Used to recover the entry's
14
+ * `kind` and source location after Rollup has bundled and hashed each
15
+ * chunk.
16
+ * @property {string} srcDir
17
+ * Absolute path to the project's `src/` — used to derive fallback `id`s
18
+ * from source file paths when `metadata.id` is missing.
19
+ * @property {string} staticOutDir
20
+ * Absolute path to Rollup's `output.dir` (typically
21
+ * `build/public/static/`). Bundled chunks live here, and we dynamically
22
+ * `import()` them to read their exported metadata.
13
23
  * @property {object} configValue
14
24
  * The serializable config object (typically `src/config.js`'s default
15
25
  * export). Inlined under `manifest.config` so the platform server doesn't
@@ -44,6 +54,13 @@ import path from 'node:path'
44
54
  * top-level key because it isn't an entry — it's the runtime context the
45
55
  * platform threads into every page render.
46
56
  *
57
+ * Metadata is read from the **bundled** output, not from the source file.
58
+ * Each entry stub re-exports `metadata` from the user's module, so once
59
+ * Rollup writes the chunk we can `await import(chunkUrl)` and read
60
+ * `mod.metadata` directly. This lets users compute metadata however they
61
+ * like (function calls, `Object.fromEntries`, values derived from imported
62
+ * config, etc.) — anything that runs at module load in Node will work.
63
+ *
47
64
  * The plugin runs in the `writeBundle` hook (rather than `generateBundle` +
48
65
  * `emitFile`) for two reasons:
49
66
  *
@@ -51,22 +68,19 @@ import path from 'node:path'
51
68
  * `output.dir` (`build/public/static/`). Rollup forbids relative paths
52
69
  * in `emitFile({ type: 'asset', fileName })`, so we write the file
53
70
  * directly with `fs` once Rollup has finished writing the chunks.
54
- * 2. Hashed chunk filenames are only stable in the post-write bundle
55
- * object, which is exactly what `writeBundle` is given.
71
+ * 2. We need the chunks on disk to dynamically `import()` them and read
72
+ * their exported `metadata`.
56
73
  *
57
- * For each entry chunk in the bundle whose `facadeModuleId` matches a
58
- * known stub (see `entriesByStub`), the plugin appends a record to
59
- * `manifest.entries` and stamps `entry: '/static/<chunk-name>'`.
60
- * Duplicate ids within a `type` abort the build — routers key on `id`
61
- * and would otherwise silently dispatch to the wrong entry.
74
+ * Duplicate ids within a `type` abort the build routers key on `id` and
75
+ * would otherwise silently dispatch to the wrong entry.
62
76
  *
63
77
  * @param {ManifestPluginOptions} options
64
78
  * @returns {import('rollup').Plugin}
65
79
  */
66
- export function manifestPlugin ({ entriesByStub, configValue, manifestPath }) {
80
+ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configValue, manifestPath }) {
67
81
  return {
68
82
  name: 'ossy-manifest',
69
- writeBundle (_, bundle) {
83
+ async writeBundle (_, bundle) {
70
84
  /** @type {ManifestEntry[]} */
71
85
  const entries = []
72
86
  const seenIds = { page: new Set(), api: new Set(), task: new Set() }
@@ -80,8 +94,23 @@ export function manifestPlugin ({ entriesByStub, configValue, manifestPath }) {
80
94
  if (!entryInfo) continue
81
95
 
82
96
  const url = '/static/' + fileName
83
- const meta = entryInfo.metadata
84
- const id = meta.id
97
+ const localPath = path.join(staticOutDir, fileName)
98
+
99
+ // Cache-bust the import so consecutive builds in a watch loop don't
100
+ // pick up a stale module from Node's loader cache.
101
+ const importHref = pathToFileURL(localPath).href + `?ts=${Date.now()}`
102
+ let mod
103
+ try {
104
+ mod = await import(importHref)
105
+ } catch (err) {
106
+ this.error(
107
+ `[@ossy/app][build] Failed to import bundled ${entryInfo.kind} entry ` +
108
+ `${entryInfo.sourcePath}: ${err && err.message ? err.message : err}`,
109
+ )
110
+ }
111
+
112
+ const rawMeta = (mod && mod.metadata) || {}
113
+ const id = rawMeta.id || metadataIdFromFile(entryInfo.sourcePath, srcDir)
85
114
  if (!id) {
86
115
  this.error(`[@ossy/app][build] ${entryInfo.kind} entry missing metadata.id: ${entryInfo.sourcePath}`)
87
116
  }
@@ -91,9 +120,10 @@ export function manifestPlugin ({ entriesByStub, configValue, manifestPath }) {
91
120
  seenIds[entryInfo.kind].add(id)
92
121
 
93
122
  if (entryInfo.kind === 'page') {
94
- entries.push({ type: 'page', id, path: meta.path, title: meta.title, entry: url })
123
+ const pagePath = rawMeta.path !== undefined ? rawMeta.path : defaultPageRoute(id)
124
+ entries.push({ type: 'page', id, path: pagePath, title: rawMeta.title, entry: url })
95
125
  } else if (entryInfo.kind === 'api') {
96
- entries.push({ type: 'api', id, path: meta.path, entry: url })
126
+ entries.push({ type: 'api', id, path: rawMeta.path, entry: url })
97
127
  } else {
98
128
  entries.push({ type: 'task', id, entry: url })
99
129
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "1.16.0",
3
+ "version": "1.16.2",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -33,15 +33,15 @@
33
33
  "@babel/eslint-parser": "^7.15.8",
34
34
  "@babel/preset-react": "^7.26.3",
35
35
  "@babel/register": "^7.25.9",
36
- "@ossy/connected-components": "^1.16.0",
37
- "@ossy/design-system": "^1.16.0",
38
- "@ossy/pages": "^1.16.0",
39
- "@ossy/platform": "^1.15.0",
40
- "@ossy/router": "^1.16.0",
41
- "@ossy/router-react": "^1.16.0",
42
- "@ossy/sdk": "^1.16.0",
43
- "@ossy/sdk-react": "^1.16.0",
44
- "@ossy/themes": "^1.16.0",
36
+ "@ossy/connected-components": "^1.16.2",
37
+ "@ossy/design-system": "^1.16.2",
38
+ "@ossy/pages": "^1.16.2",
39
+ "@ossy/platform": "^1.15.2",
40
+ "@ossy/router": "^1.16.2",
41
+ "@ossy/router-react": "^1.16.2",
42
+ "@ossy/sdk": "^1.16.2",
43
+ "@ossy/sdk-react": "^1.16.2",
44
+ "@ossy/themes": "^1.16.2",
45
45
  "@rollup/plugin-alias": "^6.0.0",
46
46
  "@rollup/plugin-babel": "6.1.0",
47
47
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -75,5 +75,5 @@
75
75
  "README.md",
76
76
  "tsconfig.json"
77
77
  ],
78
- "gitHead": "db2ab43d6b3266c59eb53c8046811e0acb3f87f1"
78
+ "gitHead": "00aca40bd8fcf8510c4a854cb388a780ddb751f7"
79
79
  }