@ossy/app 3.3.0 → 3.4.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/README.md CHANGED
@@ -48,13 +48,23 @@ export const metadata = {
48
48
  }
49
49
  ```
50
50
 
51
- **What the framework provides:** The build wraps every page automatically with `<html>`, `<head>` (including the page title and injected styles), and `<App>` (providers for theme, router, SDK). Your page component only needs to return the `<body>` element and its content.
51
+ **What the framework provides:** The build wraps every page automatically with `<html>`, `<head>` (including the page title, optional `metadata.description` / Open Graph tags, and injected styles), and `<App>` (providers for theme, router, SDK). Your page component only needs to return the `<body>` element and its content.
52
+
53
+ ```js
54
+ export const metadata = {
55
+ id: 'about',
56
+ title: 'About',
57
+ description: 'About Ossy — composable capabilities for running your business.',
58
+ ogImage: '/og-about.png',
59
+ path: '/about',
60
+ }
61
+ ```
62
+
63
+ SSR emits `<meta name="description">`, `og:title`, `og:description`, `og:type=website`, and when `config.siteUrl` or `config.domain` is set, `og:url`. Root-relative `ogImage` values (e.g. `/og.png`) are absolutized against that origin. Prefer `{pageId}.metaDescription` in translation catalogs when the copy should be localized.
52
64
 
53
65
  **Props:** The full app config is passed as props to the page component — `url`, `theme`, `isAuthenticated`, `pages`, `workspaceId`, `apiUrl`, etc. You can also access config via `useApp()` / `useRouter()` hooks from `@ossy/connected-components` and `@ossy/router-react`.
54
66
 
55
- **Build output:** Each page produces two self-contained bundles (React included):
56
- - `build/ssr/<id>.mjs` — used by the server for SSR on each request
57
- - `build/public/static/<id>.js` — loaded by the browser for hydration
67
+ **Build output:** Each page produces hashed ESM bundles under `build/public/static/` (React included) for SSR and hydration. When `config.domain` or `config.siteUrl` is set, the build also writes `build/public/sitemap.xml` from static page paths (locale maps expand to every language URL; dynamic `:param` routes and `metadata.sitemap: false` are skipped).
58
68
 
59
69
  ## Config
60
70
 
package/cli/build.task.js CHANGED
@@ -27,6 +27,7 @@ import getPlatformFiles, {
27
27
  } from './get-platform-files.task.js'
28
28
  import { manifestPlugin } from './manifest-plugin.js'
29
29
  import { mergeAndEmitTranslations } from './merge-translations.task.js'
30
+ import { emitSitemap } from './emit-sitemap.js'
30
31
 
31
32
  export { PAGE_FILE_PATTERN, API_FILE_PATTERN, TASK_FILE_PATTERN, SCHEMA_FILE_PATTERN, COMPONENT_FILE_PATTERN, AGGREGATE_FILE_PATTERN, INTEGRATION_FILE_PATTERN, STARTUP_FILE_PATTERN, EMAIL_FILE_PATTERN, ACTION_FILE_PATTERN, E2E_FILE_PATTERN, FLOW_FILE_PATTERN, LAYOUT_FILE_PATTERN, TRANSLATIONS_FILE_PATTERN }
32
33
 
@@ -454,4 +455,11 @@ export async function build (cliArgs = []) {
454
455
  configValue,
455
456
  warn: (message) => console.warn(message),
456
457
  })
458
+
459
+ emitSitemap({
460
+ publicOutDir,
461
+ manifestPath: path.join(buildPath, 'manifest.json'),
462
+ configValue,
463
+ warn: (message) => console.warn(message),
464
+ })
457
465
  }
@@ -0,0 +1,157 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ /**
5
+ * Resolve the absolute site origin used in sitemap `<loc>` values.
6
+ * Mirrors Open Graph URL base resolution (`siteUrl` / `domain`).
7
+ *
8
+ * @param {{ siteUrl?: string, domain?: string }} config
9
+ * @returns {string} Origin without trailing slash, or empty string when unset
10
+ */
11
+ export function resolveSiteOrigin (config = {}) {
12
+ if (typeof config.siteUrl === 'string' && config.siteUrl.trim()) {
13
+ return config.siteUrl.trim().replace(/\/$/, '')
14
+ }
15
+ if (typeof config.domain === 'string' && config.domain.trim()) {
16
+ const domain = config.domain.trim().replace(/\/$/, '')
17
+ return /^https?:\/\//i.test(domain) ? domain : `https://${domain}`
18
+ }
19
+ return ''
20
+ }
21
+
22
+ /**
23
+ * @param {string} routePath
24
+ * @returns {boolean}
25
+ */
26
+ export function isDynamicPagePath (routePath) {
27
+ if (typeof routePath !== 'string' || !routePath.trim()) return true
28
+ return /[:*]/.test(routePath)
29
+ }
30
+
31
+ /**
32
+ * Expand page path metadata into concrete path strings.
33
+ * Locale maps contribute every language path.
34
+ *
35
+ * @param {string | Record<string, string> | undefined} pagePath
36
+ * @returns {string[]}
37
+ */
38
+ export function expandPagePaths (pagePath) {
39
+ if (typeof pagePath === 'string') {
40
+ const trimmed = pagePath.trim()
41
+ return trimmed ? [trimmed.startsWith('/') ? trimmed : `/${trimmed}`] : []
42
+ }
43
+ if (pagePath && typeof pagePath === 'object') {
44
+ /** @type {string[]} */
45
+ const paths = []
46
+ for (const value of Object.values(pagePath)) {
47
+ if (typeof value !== 'string' || !value.trim()) continue
48
+ const trimmed = value.trim()
49
+ paths.push(trimmed.startsWith('/') ? trimmed : `/${trimmed}`)
50
+ }
51
+ return paths
52
+ }
53
+ return []
54
+ }
55
+
56
+ /**
57
+ * Collect absolute sitemap URLs from manifest page entries.
58
+ *
59
+ * @param {{
60
+ * entries?: Array<{ type?: string, path?: string | Record<string, string>, sitemap?: boolean }>
61
+ * }} manifest
62
+ * @param {string} siteOrigin
63
+ * @returns {string[]}
64
+ */
65
+ export function collectSitemapUrls (manifest, siteOrigin) {
66
+ const origin = typeof siteOrigin === 'string' ? siteOrigin.replace(/\/$/, '') : ''
67
+ if (!origin) return []
68
+
69
+ const entries = Array.isArray(manifest?.entries) ? manifest.entries : []
70
+ /** @type {Set<string>} */
71
+ const urls = new Set()
72
+
73
+ for (const entry of entries) {
74
+ if (entry?.type !== 'page') continue
75
+ if (entry.sitemap === false) continue
76
+
77
+ for (const routePath of expandPagePaths(entry.path)) {
78
+ if (isDynamicPagePath(routePath)) continue
79
+ urls.add(`${origin}${routePath === '/' ? '/' : routePath.replace(/\/$/, '') || '/'}`)
80
+ }
81
+ }
82
+
83
+ return [...urls].sort((a, b) => a.localeCompare(b))
84
+ }
85
+
86
+ /**
87
+ * @param {string[]} urls
88
+ * @returns {string}
89
+ */
90
+ export function renderSitemapXml (urls) {
91
+ const body = urls
92
+ .map((loc) => ` <url>\n <loc>${escapeXml(loc)}</loc>\n </url>`)
93
+ .join('\n')
94
+
95
+ return [
96
+ '<?xml version="1.0" encoding="UTF-8"?>',
97
+ '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
98
+ body,
99
+ '</urlset>',
100
+ '',
101
+ ].join('\n')
102
+ }
103
+
104
+ /**
105
+ * @param {string} value
106
+ * @returns {string}
107
+ */
108
+ function escapeXml (value) {
109
+ return value
110
+ .replace(/&/g, '&amp;')
111
+ .replace(/</g, '&lt;')
112
+ .replace(/>/g, '&gt;')
113
+ .replace(/"/g, '&quot;')
114
+ .replace(/'/g, '&apos;')
115
+ }
116
+
117
+ /**
118
+ * Emit `build/public/sitemap.xml` from page entries in the manifest.
119
+ * Skips when `config.domain` / `config.siteUrl` is unset.
120
+ *
121
+ * @param {{
122
+ * publicOutDir: string
123
+ * manifestPath: string
124
+ * configValue: object
125
+ * warn?: (message: string) => void
126
+ * }} options
127
+ * @returns {{ written: boolean, urlCount: number, outPath?: string }}
128
+ */
129
+ export function emitSitemap ({
130
+ publicOutDir,
131
+ manifestPath,
132
+ configValue,
133
+ warn = () => {},
134
+ }) {
135
+ const siteOrigin = resolveSiteOrigin(configValue)
136
+ if (!siteOrigin) {
137
+ warn(
138
+ '[@ossy/app][build] Skipping sitemap.xml — set config.domain or config.siteUrl to emit a page index for SEO',
139
+ )
140
+ return { written: false, urlCount: 0 }
141
+ }
142
+
143
+ if (!fs.existsSync(manifestPath)) {
144
+ warn(`[@ossy/app][build] Skipping sitemap.xml — missing manifest at ${manifestPath}`)
145
+ return { written: false, urlCount: 0 }
146
+ }
147
+
148
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
149
+ const urls = collectSitemapUrls(manifest, siteOrigin)
150
+ const xml = renderSitemapXml(urls)
151
+
152
+ fs.mkdirSync(publicOutDir, { recursive: true })
153
+ const outPath = path.join(publicOutDir, 'sitemap.xml')
154
+ fs.writeFileSync(outPath, xml, 'utf8')
155
+
156
+ return { written: true, urlCount: urls.length, outPath }
157
+ }
@@ -56,6 +56,9 @@ import {
56
56
  * @property {string | Record<string, string>} [path]
57
57
  * Required for pages and APIs; absent on tasks.
58
58
  * @property {string} [title] Optional; only meaningful for pages today.
59
+ * @property {string} [description] Optional SEO / Open Graph description for pages.
60
+ * @property {string} [ogImage] Optional Open Graph image URL for pages.
61
+ * @property {boolean} [sitemap] Optional; set `false` to exclude from build-time sitemap.xml.
59
62
  * @property {string} entry URL the platform serves the bundle from
60
63
  * (e.g. `/static/home.page-7f2a.js`).
61
64
  *
@@ -620,19 +623,28 @@ export function manifestPlugin ({ entriesByStub, srcDir, staticOutDir, configVal
620
623
  id,
621
624
  path: pagePath,
622
625
  ...(rawMeta.title != null ? { title: rawMeta.title } : {}),
626
+ ...(rawMeta.description != null ? { description: rawMeta.description } : {}),
627
+ ...(rawMeta.ogImage != null ? { ogImage: rawMeta.ogImage } : {}),
628
+ ...(rawMeta.sitemap != null ? { sitemap: rawMeta.sitemap } : {}),
623
629
  },
624
630
  { sourcePath: entryInfo.sourcePath },
625
631
  ),
626
632
  entryInfo.sourcePath,
627
633
  )
628
- entries.push(withSourcePath({
634
+ const pageEntry = {
629
635
  type: 'page',
630
636
  id,
631
637
  path: pagePath,
632
638
  title: rawMeta.title,
639
+ ...(rawMeta.description != null ? { description: rawMeta.description } : {}),
640
+ ...(rawMeta.ogImage != null ? { ogImage: rawMeta.ogImage } : {}),
633
641
  entry: url,
634
642
  package: entryPackage(entryInfo, appPackageName),
635
- }, entryInfo, projectRoot))
643
+ }
644
+ if (rawMeta.sitemap === false) {
645
+ pageEntry.sitemap = false
646
+ }
647
+ entries.push(withSourcePath(pageEntry, entryInfo, projectRoot))
636
648
  pageMetaById.set(id, {
637
649
  rawMeta,
638
650
  packageName: entryPackage(entryInfo, appPackageName),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/app",
3
- "version": "3.3.0",
3
+ "version": "3.4.0",
4
4
  "description": "",
5
5
  "source": "./src/index.js",
6
6
  "main": "./src/index.js",
@@ -49,21 +49,21 @@
49
49
  "@babel/eslint-parser": "^7.15.8",
50
50
  "@babel/preset-react": "^7.26.3",
51
51
  "@babel/register": "^7.25.9",
52
- "@ossy/authentication": "^3.2.0",
53
- "@ossy/design-system": "^3.3.0",
54
- "@ossy/locale": "^3.0.9",
55
- "@ossy/manifest": "^3.0.9",
52
+ "@ossy/authentication": "^3.4.0",
53
+ "@ossy/design-system": "^3.4.0",
54
+ "@ossy/locale": "^3.4.0",
55
+ "@ossy/manifest": "^3.4.0",
56
56
  "@ossy/package-catalog": "^3.0.9",
57
57
  "@ossy/pages": "^3.0.9",
58
- "@ossy/platform": "^3.3.0",
59
- "@ossy/resources": "^3.3.0",
58
+ "@ossy/platform": "^3.4.0",
59
+ "@ossy/resources": "^3.4.0",
60
60
  "@ossy/router": "^3.0.9",
61
61
  "@ossy/router-react": "^3.0.9",
62
- "@ossy/schema": "^3.0.9",
63
- "@ossy/sdk": "^3.3.0",
62
+ "@ossy/schema": "^3.4.0",
63
+ "@ossy/sdk": "^3.4.0",
64
64
  "@ossy/sdk-react": "^3.0.9",
65
65
  "@ossy/themes": "^3.3.0",
66
- "@ossy/workspaces": "^3.2.0",
66
+ "@ossy/workspaces": "^3.4.0",
67
67
  "@rollup/plugin-alias": "^6.0.0",
68
68
  "@rollup/plugin-babel": "^7.1.0",
69
69
  "@rollup/plugin-commonjs": "^29.0.0",
@@ -97,5 +97,5 @@
97
97
  "README.md",
98
98
  "tsconfig.json"
99
99
  ],
100
- "gitHead": "19e8bae4c1e9df79270218f28c0c9391f4f9871c"
100
+ "gitHead": "d36b69444268d172bc3e8e1dc77e67afc63f8650"
101
101
  }
@@ -0,0 +1,181 @@
1
+ import { createElement } from 'react'
2
+ import { pageIdToDocumentTitleKey, pageIdToMetaDescriptionKey, resolveMessage } from '@ossy/locale'
3
+
4
+ /**
5
+ * Resolve the document `<title>` for SSR / hydration.
6
+ * Prefers `{pageId}.documentTitle` from locale catalogs, then `metadata.title`, then `props.documentTitle`.
7
+ *
8
+ * @param {{ id?: string, title?: string }} metadata
9
+ * @param {{ messages?: Record<string, string>, fallbackMessages?: Record<string, string>, documentTitle?: string }} props
10
+ * @returns {string}
11
+ */
12
+ export function resolveDocumentTitle (metadata = {}, props = {}) {
13
+ if (metadata.id && props.messages) {
14
+ const key = pageIdToDocumentTitleKey(metadata.id)
15
+ const hasTranslation =
16
+ props.messages[key] != null || props.fallbackMessages?.[key] != null
17
+ if (hasTranslation) {
18
+ return resolveMessage(props.messages, key, {
19
+ fallbackCatalog: props.fallbackMessages,
20
+ onMissingKey: (missingKey) => {
21
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
22
+ console.warn(
23
+ `[@ossy/app][page-runtime] Missing document title key "${missingKey}" for page "${metadata.id}"`,
24
+ )
25
+ }
26
+ },
27
+ })
28
+ }
29
+ if (typeof console !== 'undefined' && typeof console.warn === 'function') {
30
+ console.warn(
31
+ `[@ossy/app][page-runtime] Missing document title key "${key}" for page "${metadata.id}"; falling back to metadata.title`,
32
+ )
33
+ }
34
+ }
35
+ return metadata.title || props.documentTitle || ''
36
+ }
37
+
38
+ /**
39
+ * Resolve the meta / Open Graph description.
40
+ * Prefers `{pageId}.metaDescription` when present in locale catalogs (no warn if missing),
41
+ * then `metadata.description`, then `props.metaDescription`.
42
+ *
43
+ * @param {{ id?: string, description?: string }} metadata
44
+ * @param {{ messages?: Record<string, string>, fallbackMessages?: Record<string, string>, metaDescription?: string }} props
45
+ * @returns {string}
46
+ */
47
+ export function resolveDocumentDescription (metadata = {}, props = {}) {
48
+ if (metadata.id && props.messages) {
49
+ const key = pageIdToMetaDescriptionKey(metadata.id)
50
+ const hasTranslation =
51
+ props.messages[key] != null || props.fallbackMessages?.[key] != null
52
+ if (hasTranslation) {
53
+ return resolveMessage(props.messages, key, {
54
+ fallbackCatalog: props.fallbackMessages,
55
+ })
56
+ }
57
+ }
58
+ if (typeof metadata.description === 'string' && metadata.description.trim()) {
59
+ return metadata.description.trim()
60
+ }
61
+ if (typeof props.metaDescription === 'string' && props.metaDescription.trim()) {
62
+ return props.metaDescription.trim()
63
+ }
64
+ return ''
65
+ }
66
+
67
+ /**
68
+ * Canonical site origin for absolute OG URLs (`https://ossy.se`).
69
+ * Prefers `siteUrl`, then `domain` (adds `https://` when scheme is omitted).
70
+ *
71
+ * @param {{ domain?: string, siteUrl?: string }} props
72
+ * @returns {string}
73
+ */
74
+ export function resolveSiteOrigin (props = {}) {
75
+ if (typeof props.siteUrl === 'string' && props.siteUrl.trim()) {
76
+ return props.siteUrl.trim().replace(/\/$/, '')
77
+ }
78
+ if (typeof props.domain === 'string' && props.domain.trim()) {
79
+ const domain = props.domain.trim().replace(/\/$/, '')
80
+ return /^https?:\/\//i.test(domain) ? domain : `https://${domain}`
81
+ }
82
+ return ''
83
+ }
84
+
85
+ /**
86
+ * Turn a site-root path into an absolute URL when a site origin is known.
87
+ * Absolute http(s) URLs are returned unchanged.
88
+ *
89
+ * @param {string} value
90
+ * @param {{ domain?: string, siteUrl?: string }} props
91
+ * @returns {string}
92
+ */
93
+ export function absolutizeSiteUrl (value, props = {}) {
94
+ const trimmed = typeof value === 'string' ? value.trim() : ''
95
+ if (!trimmed) return ''
96
+ if (/^https?:\/\//i.test(trimmed)) return trimmed
97
+
98
+ const origin = resolveSiteOrigin(props)
99
+ if (!origin) return trimmed
100
+
101
+ if (trimmed.startsWith('//')) {
102
+ const scheme = origin.startsWith('http://') ? 'http:' : 'https:'
103
+ return `${scheme}${trimmed}`
104
+ }
105
+
106
+ return `${origin}${trimmed.startsWith('/') ? trimmed : `/${trimmed}`}`
107
+ }
108
+
109
+ /**
110
+ * Build an absolute Open Graph URL from app domain + request path.
111
+ *
112
+ * @param {{ url?: string, domain?: string, siteUrl?: string }} props
113
+ * @returns {string}
114
+ */
115
+ export function resolveOpenGraphUrl (props = {}) {
116
+ const path = typeof props.url === 'string' ? props.url.split(/[?#]/)[0] || '/' : ''
117
+ if (!path) return ''
118
+
119
+ const siteUrl = resolveSiteOrigin(props)
120
+ if (!siteUrl) return ''
121
+ return `${siteUrl}${path.startsWith('/') ? path : `/${path}`}`
122
+ }
123
+
124
+ /**
125
+ * Resolve optional OG / Twitter image URL from page metadata or app settings.
126
+ * Root-relative paths are absolutized against `siteUrl` / `domain` when available.
127
+ *
128
+ * @param {{ ogImage?: string }} metadata
129
+ * @param {{ ogImage?: string, domain?: string, siteUrl?: string }} props
130
+ * @returns {string}
131
+ */
132
+ export function resolveOpenGraphImage (metadata = {}, props = {}) {
133
+ const raw =
134
+ (typeof metadata.ogImage === 'string' && metadata.ogImage.trim())
135
+ || (typeof props.ogImage === 'string' && props.ogImage.trim())
136
+ || ''
137
+ return absolutizeSiteUrl(raw, props)
138
+ }
139
+
140
+ /**
141
+ * Build `<head>` children for SSR: charset, viewport, title, description, Open Graph.
142
+ *
143
+ * @param {{ metadata: object, props: object, viewportContent: string }} options
144
+ * @returns {import('react').ReactNode[]}
145
+ */
146
+ export function buildDocumentHeadChildren ({ metadata, props, viewportContent }) {
147
+ const title = resolveDocumentTitle(metadata, props)
148
+ const description = resolveDocumentDescription(metadata, props)
149
+ const ogUrl = resolveOpenGraphUrl(props)
150
+ const ogImage = resolveOpenGraphImage(metadata, props)
151
+
152
+ /** @type {import('react').ReactNode[]} */
153
+ const children = [
154
+ createElement('meta', { key: 'charset', charSet: 'utf-8' }),
155
+ createElement('meta', { key: 'viewport', name: 'viewport', content: viewportContent }),
156
+ createElement('title', { key: 'title' }, title),
157
+ ]
158
+
159
+ if (description) {
160
+ children.push(createElement('meta', { key: 'description', name: 'description', content: description }))
161
+ }
162
+
163
+ if (title) {
164
+ children.push(createElement('meta', { key: 'og:title', property: 'og:title', content: title }))
165
+ }
166
+ if (description) {
167
+ children.push(createElement('meta', { key: 'og:description', property: 'og:description', content: description }))
168
+ }
169
+ children.push(createElement('meta', { key: 'og:type', property: 'og:type', content: 'website' }))
170
+ if (ogUrl) {
171
+ children.push(createElement('meta', { key: 'og:url', property: 'og:url', content: ogUrl }))
172
+ }
173
+ if (ogImage) {
174
+ children.push(createElement('meta', { key: 'og:image', property: 'og:image', content: ogImage }))
175
+ children.push(createElement('meta', { key: 'twitter:card', name: 'twitter:card', content: 'summary_large_image' }))
176
+ } else if (description || title) {
177
+ children.push(createElement('meta', { key: 'twitter:card', name: 'twitter:card', content: 'summary' }))
178
+ }
179
+
180
+ return children
181
+ }
@@ -1,11 +1,20 @@
1
1
  import { createElement } from 'react'
2
- import { pageIdToDocumentTitleKey, resolveMessage } from '@ossy/locale'
3
2
  import { Slot } from '@ossy/design-system'
4
3
  import { App } from '../src/shell/App.jsx'
5
4
  import { resolvePageSlots, CONTENT_SLOT_NAME } from './resolve-app-slots.js'
6
5
  import { DOCUMENT_VIEWPORT_CONTENT } from './document-viewport.js'
6
+ import { buildDocumentHeadChildren } from './document-meta.js'
7
7
 
8
8
  export { DOCUMENT_VIEWPORT_CONTENT } from './document-viewport.js'
9
+ export {
10
+ resolveDocumentTitle,
11
+ resolveDocumentDescription,
12
+ resolveSiteOrigin,
13
+ absolutizeSiteUrl,
14
+ resolveOpenGraphUrl,
15
+ resolveOpenGraphImage,
16
+ buildDocumentHeadChildren,
17
+ } from './document-meta.js'
9
18
 
10
19
  /**
11
20
  * Dynamically imports each component bundle listed in `entries` and returns a
@@ -57,32 +66,6 @@ export async function loadLayout (layoutEntry) {
57
66
  }
58
67
  }
59
68
 
60
- function resolveDocumentTitle (metadata, props) {
61
- if (metadata.id && props.messages) {
62
- const key = pageIdToDocumentTitleKey(metadata.id)
63
- const hasTranslation =
64
- props.messages[key] != null || props.fallbackMessages?.[key] != null
65
- if (hasTranslation) {
66
- return resolveMessage(props.messages, key, {
67
- fallbackCatalog: props.fallbackMessages,
68
- onMissingKey: (missingKey) => {
69
- if (typeof console !== 'undefined' && typeof console.warn === 'function') {
70
- console.warn(
71
- `[@ossy/app][page-runtime] Missing document title key "${missingKey}" for page "${metadata.id}"`,
72
- )
73
- }
74
- },
75
- })
76
- }
77
- if (typeof console !== 'undefined' && typeof console.warn === 'function') {
78
- console.warn(
79
- `[@ossy/app][page-runtime] Missing document title key "${key}" for page "${metadata.id}"; falling back to metadata.title`,
80
- )
81
- }
82
- }
83
- return metadata.title || props.documentTitle || ''
84
- }
85
-
86
69
  /** Page body is registered on `app:content`; layouts only gate shell chrome around it. */
87
70
  function buildTree ({ Layout, metadata, props }) {
88
71
  const lang = props.language || props.defaultLanguage || 'en'
@@ -95,9 +78,11 @@ function buildTree ({ Layout, metadata, props }) {
95
78
  createElement(
96
79
  'head',
97
80
  null,
98
- createElement('meta', { charSet: 'utf-8' }),
99
- createElement('meta', { name: 'viewport', content: DOCUMENT_VIEWPORT_CONTENT }),
100
- createElement('title', null, resolveDocumentTitle(metadata, props)),
81
+ ...buildDocumentHeadChildren({
82
+ metadata,
83
+ props,
84
+ viewportContent: DOCUMENT_VIEWPORT_CONTENT,
85
+ }),
101
86
  ),
102
87
  createElement('body', { style: { height: '100%', margin: 0 } }, createElement(App, props, contentEl)),
103
88
  )
package/src/shell/App.jsx CHANGED
@@ -7,6 +7,7 @@ import { DEFAULT_SCHEMA_FORM_SLOTS } from '@ossy/resources/schemaFormSlots.js'
7
7
  import { SchemasReadBootstrap } from '@ossy/workspaces/SchemasReadBootstrap'
8
8
  import { ThemeEditor } from './ThemeEditor.jsx'
9
9
  import { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
10
+ import { PageViewTracker } from './PageViewTracker.jsx'
10
11
  import { defaultAppSettings } from './AppSettings.jsx'
11
12
  import { Router } from '@ossy/router-react'
12
13
  import { AppContext } from './AppContext.js'
@@ -41,6 +42,7 @@ export const App = ({ children, language, messages, fallbackMessages, sdk: sdkOv
41
42
  <SchemasReadBootstrap schemas={appSettings.schemas} />
42
43
  <WorkspaceAppSettingsSync />
43
44
  <Router {...appSettings} pages={appSettings.pages || []}>
45
+ <PageViewTracker />
44
46
  {children}
45
47
  {appSettings.devMode && <ThemeEditor />}
46
48
  </Router>
@@ -17,7 +17,17 @@ export function defaultAppSettings() {
17
17
  /** Site / app label (e.g. chrome); not wired to `<title>` — pages own document title. */
18
18
  documentTitle: undefined,
19
19
  title: undefined,
20
+ /** Site-wide meta description fallback when a page omits `metadata.description`. */
20
21
  metaDescription: undefined,
22
+ /** Optional default Open Graph image URL for pages without `metadata.ogImage`. */
23
+ ogImage: undefined,
24
+ /**
25
+ * Public site origin for absolute `og:url` / `og:image` (e.g. `https://ossy.se`).
26
+ * Prefer over `domain` when the scheme is not https or the host includes a port.
27
+ */
28
+ siteUrl: undefined,
29
+ /** Hostname or URL used to build absolute Open Graph URLs when `siteUrl` is unset. */
30
+ domain: undefined,
21
31
  themeColor: undefined,
22
32
  /** `<html lang>`; falls back to `defaultLanguage` then `en`. */
23
33
  htmlLang: undefined,
@@ -33,16 +33,16 @@ export function HeaderAuthActions ({ compact = false }) {
33
33
  return (
34
34
  <>
35
35
  <Button
36
+ {...OpenSignIn}
36
37
  variant="link"
37
- suffix={OpenSignIn.suffix}
38
38
  href={router.getHref('@sign-in')}
39
39
  label={compact ? undefined : OpenSignIn.label}
40
40
  aria-label={t(OpenSignIn.label)}
41
41
  style={controlStyle}
42
42
  />
43
43
  <Button
44
+ {...OpenSignUp}
44
45
  variant="cta"
45
- suffix={OpenSignUp.suffix}
46
46
  href={router.getHref('@sign-up')}
47
47
  label={compact ? undefined : OpenSignUp.label}
48
48
  aria-label={t(OpenSignUp.label)}
@@ -0,0 +1,45 @@
1
+ import { useEffect } from 'react'
2
+ import { useRouter } from '@ossy/router-react'
3
+ import { useSdk } from '@ossy/sdk-react'
4
+ import { GetWorkspace } from '@ossy/workspaces'
5
+ import { CreatePageView } from '@ossy/resources'
6
+
7
+ /** Survives Strict Mode remounts so the same navigation is not double-counted. */
8
+ let lastRecordedKey = null
9
+ let lastRecordedAt = 0
10
+ const DEDUPE_MS = 1500
11
+
12
+ /**
13
+ * Emit a page-view resource on client navigations so analytics can aggregate traffic.
14
+ * No UI — mount once under the app shell / router.
15
+ */
16
+ export function PageViewTracker () {
17
+ const router = useRouter()
18
+ const sdk = useSdk()
19
+ const { data: workspace } = sdk.read(GetWorkspace)
20
+
21
+ useEffect(() => {
22
+ if (typeof window === 'undefined') return
23
+ if (!workspace?.id) return
24
+
25
+ const path = window.location.pathname || '/'
26
+ const section = window.location.hash
27
+ ? window.location.hash.replace('#', '')
28
+ : undefined
29
+ const key = `${workspace.id}:${path}:${section || ''}:${router.language || ''}`
30
+ const now = Date.now()
31
+ if (lastRecordedKey === key && now - lastRecordedAt < DEDUPE_MS) return
32
+ lastRecordedKey = key
33
+ lastRecordedAt = now
34
+
35
+ sdk.invoke(CreatePageView, {
36
+ path,
37
+ section,
38
+ language: router.language,
39
+ referrer: typeof document !== 'undefined' ? (document.referrer || undefined) : undefined,
40
+ eventAt: now,
41
+ }).catch(() => {})
42
+ }, [sdk, router.href, router.language, workspace?.id])
43
+
44
+ return null
45
+ }
@@ -5,6 +5,7 @@ export * from './ThemeEditor.jsx'
5
5
  export { patchUserAppSettings } from './patchUserAppSettings.js'
6
6
  export { useShellWorkspace } from './useShellWorkspace.js'
7
7
  export { WorkspaceAppSettingsSync } from './WorkspaceAppSettingsSync.jsx'
8
+ export { PageViewTracker } from './PageViewTracker.jsx'
8
9
  export { resolveEndpoints } from './resolveEndpoints.js'
9
10
  export { resolveWorkspaceServices } from './resolveWorkspaceServices.js'
10
11
  export {