@ossy/app 3.2.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
 
@@ -170,6 +180,22 @@ export default function ServiceForm() { … }
170
180
 
171
181
  See [`docs/component-primitive.md`](./docs/component-primitive.md) and [design-system/docs/SLOTS.md](../design-system/docs/SLOTS.md).
172
182
 
183
+ ## Compact / mobile shell
184
+
185
+ Default and workspace layouts use `@ossy/app/shell` helpers for viewports ≤900px:
186
+
187
+ - `useCompactShellLayout` / `COMPACT_SHELL_MEDIA_QUERY` — match Cloud theme compact typography
188
+ - `ShellHeaderRow` + `MobileShellNav` — menu control + overlay drawer hosting `app:sidebar` with `presentation="drawer"`
189
+ - `OpenMobileShellNav` / `CloseMobileShellNav` action POJOs on the menu/close controls (`data-action`) for flow coverage with `{ viewport }` / `{ press: 'Escape' }` / `{ press: 'Tab' }` / `{ press: 'Shift+Tab' }`
190
+ - Header row stays when `app:header` is unset so sidebar-only pages keep primary nav
191
+ - Drawer traps Tab focus via a `View` panel ref (flow asserts wrap with `Shift+Tab` / `Tab`), restores the menu trigger on close, and keeps menu/close hit areas ≥44px
192
+ - While open, `[data-ossy-app-shell]` is `inert` + `aria-hidden`; body gets `data-ossy-mobile-shell-scroll-lock`; the drawer panel respects `env(safe-area-inset-*)`
193
+ - Compact shells pad with the same safe-area insets (not zero) so the header menu clears notches; document head uses `viewport-fit=cover`
194
+ - Compact header chrome (language / theme / auth) uses the same ≥44px hit areas via `compactShellControlStyle`
195
+ - Tab-trap helpers live in `mobileShellFocus.js` (`listFocusable`, `resolveTabTrapTarget`, `compactShellControlStyle`, `compactAppShellStyle`, `setAppShellBackgroundInert`, `setMobileShellScrollLock`, `mobileShellDrawerSafeAreaStyle`) with unit coverage in `__tests__/mobileShellFocus.test.js`
196
+
197
+ See [SHELL-SPEC §4.5](../../docs/concepts/SHELL-SPEC.md).
198
+
173
199
  ## Port configuration
174
200
 
175
201
  The server listens on port **3000** by default.
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.2.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.2.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.2.0",
59
- "@ossy/resources": "^3.2.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.2.0",
62
+ "@ossy/schema": "^3.4.0",
63
+ "@ossy/sdk": "^3.4.0",
64
64
  "@ossy/sdk-react": "^3.0.9",
65
- "@ossy/themes": "^3.2.0",
66
- "@ossy/workspaces": "^3.2.0",
65
+ "@ossy/themes": "^3.3.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": "b045f1fd7f2fe00c776b9ea96f76083b93fd8556"
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
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Document viewport for mobile / iPad (and notched devices).
3
+ * `viewport-fit=cover` lets `env(safe-area-inset-*)` resolve so compact shell chrome
4
+ * and the mobile nav drawer can clear the status bar / home indicator.
5
+ */
6
+ export const DOCUMENT_VIEWPORT_CONTENT =
7
+ 'width=device-width, initial-scale=1, viewport-fit=cover'
@@ -1,8 +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'
5
+ import { DOCUMENT_VIEWPORT_CONTENT } from './document-viewport.js'
6
+ import { buildDocumentHeadChildren } from './document-meta.js'
7
+
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'
6
18
 
7
19
  /**
8
20
  * Dynamically imports each component bundle listed in `entries` and returns a
@@ -54,32 +66,6 @@ export async function loadLayout (layoutEntry) {
54
66
  }
55
67
  }
56
68
 
57
- function resolveDocumentTitle (metadata, props) {
58
- if (metadata.id && props.messages) {
59
- const key = pageIdToDocumentTitleKey(metadata.id)
60
- const hasTranslation =
61
- props.messages[key] != null || props.fallbackMessages?.[key] != null
62
- if (hasTranslation) {
63
- return resolveMessage(props.messages, key, {
64
- fallbackCatalog: props.fallbackMessages,
65
- onMissingKey: (missingKey) => {
66
- if (typeof console !== 'undefined' && typeof console.warn === 'function') {
67
- console.warn(
68
- `[@ossy/app][page-runtime] Missing document title key "${missingKey}" for page "${metadata.id}"`,
69
- )
70
- }
71
- },
72
- })
73
- }
74
- if (typeof console !== 'undefined' && typeof console.warn === 'function') {
75
- console.warn(
76
- `[@ossy/app][page-runtime] Missing document title key "${key}" for page "${metadata.id}"; falling back to metadata.title`,
77
- )
78
- }
79
- }
80
- return metadata.title || props.documentTitle || ''
81
- }
82
-
83
69
  /** Page body is registered on `app:content`; layouts only gate shell chrome around it. */
84
70
  function buildTree ({ Layout, metadata, props }) {
85
71
  const lang = props.language || props.defaultLanguage || 'en'
@@ -92,8 +78,11 @@ function buildTree ({ Layout, metadata, props }) {
92
78
  createElement(
93
79
  'head',
94
80
  null,
95
- createElement('meta', { charSet: 'utf-8' }),
96
- createElement('title', null, resolveDocumentTitle(metadata, props)),
81
+ ...buildDocumentHeadChildren({
82
+ metadata,
83
+ props,
84
+ viewportContent: DOCUMENT_VIEWPORT_CONTENT,
85
+ }),
97
86
  ),
98
87
  createElement('body', { style: { height: '100%', margin: 0 } }, createElement(App, props, contentEl)),
99
88
  )
@@ -4,5 +4,8 @@
4
4
  "app.shell.header.languagePicker": "Language",
5
5
  "app.shell.footer.languages": "Languages",
6
6
  "app.shell.header.themeSwitch": "{theme} theme. Switch theme.",
7
- "app.shell.header.profile": "Profile"
7
+ "app.shell.header.profile": "Profile",
8
+ "app.shell.header.openNav": "Open navigation",
9
+ "app.shell.header.closeNav": "Close navigation",
10
+ "app.shell.header.nav": "Primary navigation"
8
11
  }
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,
@@ -3,6 +3,7 @@ import { Button, useLocale } from '@ossy/design-system'
3
3
  import { useRouter } from '@ossy/router-react'
4
4
  import { OpenSignIn, OpenSignUp } from '@ossy/authentication'
5
5
  import { useApp } from './AppContext.js'
6
+ import { compactShellControlStyle } from './mobileShellFocus.js'
6
7
 
7
8
  /**
8
9
  * Profile link when authenticated; sign-in / sign-up when not.
@@ -11,6 +12,9 @@ export function HeaderAuthActions ({ compact = false }) {
11
12
  const app = useApp()
12
13
  const router = useRouter()
13
14
  const { t } = useLocale()
15
+ const controlStyle = compact
16
+ ? compactShellControlStyle()
17
+ : { flexShrink: 0 }
14
18
 
15
19
  if (app?.isAuthenticated) {
16
20
  return (
@@ -19,7 +23,7 @@ export function HeaderAuthActions ({ compact = false }) {
19
23
  suffix="profile"
20
24
  href={router.getHref('@profile')}
21
25
  aria-label={t('app.shell.header.profile') || 'Profile'}
22
- style={{ flexShrink: 0 }}
26
+ style={controlStyle}
23
27
  >
24
28
  {compact ? null : (t('app.shell.header.profile') || 'Profile')}
25
29
  </Button>
@@ -29,20 +33,20 @@ export function HeaderAuthActions ({ compact = false }) {
29
33
  return (
30
34
  <>
31
35
  <Button
36
+ {...OpenSignIn}
32
37
  variant="link"
33
- suffix={OpenSignIn.suffix}
34
38
  href={router.getHref('@sign-in')}
35
39
  label={compact ? undefined : OpenSignIn.label}
36
40
  aria-label={t(OpenSignIn.label)}
37
- style={{ flexShrink: 0 }}
41
+ style={controlStyle}
38
42
  />
39
43
  <Button
44
+ {...OpenSignUp}
40
45
  variant="cta"
41
- suffix={OpenSignUp.suffix}
42
46
  href={router.getHref('@sign-up')}
43
47
  label={compact ? undefined : OpenSignUp.label}
44
48
  aria-label={t(OpenSignUp.label)}
45
- style={{ flexShrink: 0 }}
49
+ style={controlStyle}
46
50
  />
47
51
  </>
48
52
  )