@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 +14 -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 +11 -11
- package/runtime/document-meta.js +181 -0
- package/runtime/page-runtime.js +15 -30
- package/src/shell/App.jsx +2 -0
- package/src/shell/AppSettings.jsx +10 -0
- package/src/shell/HeaderAuthActions.jsx +2 -2
- package/src/shell/PageViewTracker.jsx +45 -0
- package/src/shell/index.js +1 -0
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
|
|
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
65
|
"@ossy/themes": "^3.3.0",
|
|
66
|
-
"@ossy/workspaces": "^3.
|
|
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
|
+
}
|
package/runtime/page-runtime.js
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
+
}
|
package/src/shell/index.js
CHANGED
|
@@ -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 {
|