@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 +30 -4
- package/cli/build.task.js +8 -0
- package/cli/emit-sitemap.js +157 -0
- package/cli/manifest-plugin.js +14 -2
- package/package.json +12 -12
- package/runtime/document-meta.js +181 -0
- package/runtime/document-viewport.js +7 -0
- package/runtime/page-runtime.js +18 -29
- package/src/en.translations.json +4 -1
- package/src/shell/App.jsx +2 -0
- package/src/shell/AppSettings.jsx +10 -0
- package/src/shell/HeaderAuthActions.jsx +9 -5
- package/src/shell/LanguageSelect.jsx +7 -3
- package/src/shell/MobileShellNav.jsx +180 -0
- package/src/shell/PageViewTracker.jsx +45 -0
- package/src/shell/ShellHeaderRow.jsx +49 -0
- package/src/shell/ThemeSelect.jsx +6 -2
- package/src/shell/index.js +28 -1
- package/src/shell/mobileShellFocus.js +196 -0
- package/src/shell/shellChrome.js +24 -0
- package/src/shell/sidebarChrome.js +60 -0
- package/src/shell/useCompactShellLayout.js +4 -4
- package/src/shell-registry/close-mobile-shell-nav.action.js +6 -0
- package/src/shell-registry/default.layout.jsx +30 -12
- package/src/shell-registry/header-default.component.jsx +1 -1
- package/src/shell-registry/open-mobile-shell-nav.action.js +6 -0
- package/src/shell-registry/sidebar-default.component.jsx +25 -6
- package/src/shell-registry/workspace.layout.jsx +30 -12
- package/src/sv.translations.json +4 -1
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
|
|
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, '&')
|
|
111
|
+
.replace(/</g, '<')
|
|
112
|
+
.replace(/>/g, '>')
|
|
113
|
+
.replace(/"/g, '"')
|
|
114
|
+
.replace(/'/g, ''')
|
|
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
|
+
}
|
package/cli/manifest-plugin.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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
|
+
"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.
|
|
53
|
-
"@ossy/design-system": "^3.
|
|
54
|
-
"@ossy/locale": "^3.0
|
|
55
|
-
"@ossy/manifest": "^3.0
|
|
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.
|
|
59
|
-
"@ossy/resources": "^3.
|
|
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
|
|
63
|
-
"@ossy/sdk": "^3.
|
|
62
|
+
"@ossy/schema": "^3.4.0",
|
|
63
|
+
"@ossy/sdk": "^3.4.0",
|
|
64
64
|
"@ossy/sdk-react": "^3.0.9",
|
|
65
|
-
"@ossy/themes": "^3.
|
|
66
|
-
"@ossy/workspaces": "^3.
|
|
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": "
|
|
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'
|
package/runtime/page-runtime.js
CHANGED
|
@@ -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
|
-
|
|
96
|
-
|
|
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
|
)
|
package/src/en.translations.json
CHANGED
|
@@ -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={
|
|
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={
|
|
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={
|
|
49
|
+
style={controlStyle}
|
|
46
50
|
/>
|
|
47
51
|
</>
|
|
48
52
|
)
|