@payload-onpage/plugin 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +15 -2
- package/dist/access.d.ts +20 -0
- package/dist/access.d.ts.map +1 -0
- package/dist/access.js +51 -0
- package/dist/access.js.map +1 -0
- package/dist/admin/OnPageAdminControls.d.ts +9 -0
- package/dist/admin/OnPageAdminControls.d.ts.map +1 -0
- package/dist/admin/OnPageAdminControls.js +59 -0
- package/dist/admin/OnPageAdminControls.js.map +1 -0
- package/dist/admin/index.d.ts +3 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/admin/index.js +2 -0
- package/dist/admin/index.js.map +1 -0
- package/dist/endpoints.d.ts +11 -0
- package/dist/endpoints.d.ts.map +1 -0
- package/dist/endpoints.js +223 -0
- package/dist/endpoints.js.map +1 -0
- package/dist/hooks.d.ts +38 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +42 -0
- package/dist/hooks.js.map +1 -0
- package/dist/http.d.ts +23 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +53 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/dist/options.d.ts +59 -0
- package/dist/options.d.ts.map +1 -0
- package/dist/options.js +4 -0
- package/dist/options.js.map +1 -0
- package/dist/paths.d.ts +11 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +58 -0
- package/dist/paths.js.map +1 -0
- package/dist/schema.d.ts +34 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +209 -0
- package/dist/schema.js.map +1 -0
- package/dist/tokens.d.ts +18 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +24 -0
- package/dist/tokens.js.map +1 -0
- package/dist/translations.d.ts +45 -0
- package/dist/translations.d.ts.map +1 -0
- package/dist/translations.js +44 -0
- package/dist/translations.js.map +1 -0
- package/package.json +65 -4
- package/src/access.ts +65 -0
- package/src/admin/OnPageAdminControls.tsx +99 -0
- package/src/admin/index.ts +2 -0
- package/src/endpoints.ts +269 -0
- package/src/hooks.ts +79 -0
- package/src/http.ts +57 -0
- package/src/index.ts +167 -0
- package/src/options.ts +65 -0
- package/src/paths.ts +59 -0
- package/src/schema.ts +251 -0
- package/src/tokens.ts +46 -0
- package/src/translations.ts +45 -0
package/src/paths.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/** Where a document lives on the site. */
|
|
2
|
+
import { safePath, type DocRef } from '@payload-onpage/core'
|
|
3
|
+
import type { PayloadRequest } from 'payload'
|
|
4
|
+
import { originOf } from './http.ts'
|
|
5
|
+
import type { Entry } from './options.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The site path that shows a document: the entry's `url` option, else the
|
|
9
|
+
* collection's `admin.preview` URL, else `/<slug>`; globals default to "/".
|
|
10
|
+
* Absolute URLs must be same-origin. Returns null when there's no page.
|
|
11
|
+
*/
|
|
12
|
+
export async function resolvePath(
|
|
13
|
+
req: PayloadRequest,
|
|
14
|
+
ref: DocRef,
|
|
15
|
+
entry: Entry,
|
|
16
|
+
locale?: string,
|
|
17
|
+
): Promise<string | null> {
|
|
18
|
+
const localeArg = locale ? { locale: locale as never } : {}
|
|
19
|
+
const doc =
|
|
20
|
+
ref.kind === 'global'
|
|
21
|
+
? await req.payload
|
|
22
|
+
.findGlobal({
|
|
23
|
+
slug: ref.slug as never,
|
|
24
|
+
draft: true,
|
|
25
|
+
depth: 0,
|
|
26
|
+
overrideAccess: true,
|
|
27
|
+
...localeArg,
|
|
28
|
+
})
|
|
29
|
+
.catch(() => null)
|
|
30
|
+
: await req.payload
|
|
31
|
+
.findByID({
|
|
32
|
+
collection: ref.slug as never,
|
|
33
|
+
id: ref.id,
|
|
34
|
+
draft: true,
|
|
35
|
+
depth: 0,
|
|
36
|
+
overrideAccess: true,
|
|
37
|
+
...localeArg,
|
|
38
|
+
})
|
|
39
|
+
.catch(() => null)
|
|
40
|
+
if (!doc) return null
|
|
41
|
+
|
|
42
|
+
let url: string | null | undefined
|
|
43
|
+
if (entry.options.url) url = await entry.options.url({ doc, locale, req })
|
|
44
|
+
else if (ref.kind === 'collection') {
|
|
45
|
+
const preview = req.payload.collections[ref.slug]?.config.admin?.preview
|
|
46
|
+
if (preview) url = await preview(doc, { locale: locale ?? '', req, token: null })
|
|
47
|
+
if (!url && typeof doc.slug === 'string') url = `/${doc.slug.replace(/^\/+/, '')}`
|
|
48
|
+
} else url = '/'
|
|
49
|
+
if (!url) return null
|
|
50
|
+
|
|
51
|
+
const origin = originOf(req)
|
|
52
|
+
try {
|
|
53
|
+
const u = new URL(url, origin)
|
|
54
|
+
if (u.origin !== origin) return null
|
|
55
|
+
return safePath(u.pathname + u.search)
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the editor's JSON schema from Payload field configs, using Payload's
|
|
3
|
+
* own field predicates and translations. Payload's client config can't be used
|
|
4
|
+
* instead: it strips `defaultValue` and `custom`, which the editor needs.
|
|
5
|
+
*/
|
|
6
|
+
import { getTranslation } from '@payloadcms/translations'
|
|
7
|
+
import type { DocumentSchema, SchemaBlock, SchemaField } from '@payload-onpage/core'
|
|
8
|
+
import type { Block, Field, PayloadRequest, SanitizedFieldsPermissions } from 'payload'
|
|
9
|
+
import { getDefaultValue } from 'payload'
|
|
10
|
+
import {
|
|
11
|
+
fieldAffectsData,
|
|
12
|
+
fieldIsHiddenOrDisabled,
|
|
13
|
+
fieldIsVirtual,
|
|
14
|
+
tabHasName,
|
|
15
|
+
formatLabels,
|
|
16
|
+
toWords,
|
|
17
|
+
} from 'payload/shared'
|
|
18
|
+
|
|
19
|
+
/** Fields Payload manages itself; never editable on the page. */
|
|
20
|
+
const SYSTEM_FIELDS = new Set(['id', 'createdAt', 'updatedAt', '_status', 'deletedAt'])
|
|
21
|
+
/** Data fields with no meaningful on-page value. */
|
|
22
|
+
const SKIP_TYPES = new Set(['join', 'ui'])
|
|
23
|
+
|
|
24
|
+
/** The optional, type-specific field properties the serializer reads. */
|
|
25
|
+
interface LooseField {
|
|
26
|
+
label?: unknown
|
|
27
|
+
labels?: { singular?: unknown; plural?: unknown }
|
|
28
|
+
localized?: boolean
|
|
29
|
+
required?: boolean
|
|
30
|
+
hasMany?: boolean
|
|
31
|
+
defaultValue?: unknown
|
|
32
|
+
options?: unknown[]
|
|
33
|
+
relationTo?: string | string[]
|
|
34
|
+
minRows?: number
|
|
35
|
+
maxRows?: number
|
|
36
|
+
min?: number
|
|
37
|
+
max?: number
|
|
38
|
+
custom?: { onpage?: { editor?: unknown } }
|
|
39
|
+
admin?: {
|
|
40
|
+
description?: unknown
|
|
41
|
+
custom?: { onpage?: { editor?: unknown } }
|
|
42
|
+
date?: { pickerAppearance?: unknown }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type I18nLike = Parameters<typeof getTranslation>[1]
|
|
47
|
+
type AnyLabel = Parameters<typeof getTranslation>[0]
|
|
48
|
+
type Permissions = SanitizedFieldsPermissions | undefined
|
|
49
|
+
|
|
50
|
+
export interface SerializeContext {
|
|
51
|
+
/** Translates labels into the admin language. Without it, plain-string labels or the field name are used. */
|
|
52
|
+
i18n?: I18nLike
|
|
53
|
+
/** Global blocks (`config.blocks`), to resolve `blockReferences`. */
|
|
54
|
+
blocks?: Block[]
|
|
55
|
+
/** Field-level permissions: fields the user can't update are left out. */
|
|
56
|
+
permissions?: Permissions
|
|
57
|
+
/** Collects function defaults to evaluate for this request. */
|
|
58
|
+
defaults?: { req: PayloadRequest; pending: Promise<void>[] }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const label = (value: unknown, fallback: string, ctx: SerializeContext): string => {
|
|
62
|
+
if (value == null || value === false) return fallback
|
|
63
|
+
if (typeof value === 'string') return value || fallback
|
|
64
|
+
if (!ctx.i18n) return fallback
|
|
65
|
+
try {
|
|
66
|
+
const out = getTranslation(value as AnyLabel, ctx.i18n)
|
|
67
|
+
return typeof out === 'string' && out ? out : fallback
|
|
68
|
+
} catch {
|
|
69
|
+
return fallback
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Sanitized permissions: `true` = everything; a missing entry = denied. */
|
|
74
|
+
function childPermissions(perms: Permissions, name: string): Permissions | false {
|
|
75
|
+
if (perms === undefined || perms === true) return perms
|
|
76
|
+
const entry = perms[name]
|
|
77
|
+
if (!entry) return false
|
|
78
|
+
if (entry === true) return true
|
|
79
|
+
if (entry.update !== true) return false
|
|
80
|
+
return entry.fields ?? true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function blockPermissions(perms: Permissions, name: string, slug: string): Permissions {
|
|
84
|
+
if (perms === undefined || perms === true) return perms
|
|
85
|
+
const entry = perms[name]
|
|
86
|
+
if (!entry || entry === true) return true
|
|
87
|
+
const block = (
|
|
88
|
+
entry.blocks as Record<string, { fields?: Permissions } | true | undefined> | undefined
|
|
89
|
+
)?.[slug]
|
|
90
|
+
// No block-level rule: the blocks field's own permission (already checked) applies.
|
|
91
|
+
if (!block || block === true) return true
|
|
92
|
+
return block.fields ?? true
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const isJsonSafe = (v: unknown): boolean => {
|
|
96
|
+
if (v === undefined || typeof v === 'function') return false
|
|
97
|
+
try {
|
|
98
|
+
JSON.stringify(v)
|
|
99
|
+
return true
|
|
100
|
+
} catch {
|
|
101
|
+
return false
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function serializeBlock(block: Block, perms: Permissions, ctx: SerializeContext): SchemaBlock {
|
|
106
|
+
return {
|
|
107
|
+
slug: block.slug,
|
|
108
|
+
labels: {
|
|
109
|
+
singular: label(block.labels?.singular, toWords(block.slug), ctx),
|
|
110
|
+
plural: label(block.labels?.plural, toWords(block.slug), ctx),
|
|
111
|
+
},
|
|
112
|
+
fields: serializeFields(block.fields, { ...ctx, permissions: perms }),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Serialize Payload field configs into the editor schema. Presentational
|
|
118
|
+
* containers (row, collapsible, unnamed group/tab) are flattened into their
|
|
119
|
+
* parent; named tabs become groups; hidden, disabled, virtual, system and
|
|
120
|
+
* join/ui fields are left out.
|
|
121
|
+
*/
|
|
122
|
+
export function serializeFields(
|
|
123
|
+
fields: Field[] | undefined,
|
|
124
|
+
ctx: SerializeContext = {},
|
|
125
|
+
): SchemaField[] {
|
|
126
|
+
const out: SchemaField[] = []
|
|
127
|
+
for (const f of fields ?? []) {
|
|
128
|
+
if (!f || typeof f !== 'object' || SKIP_TYPES.has(f.type)) continue
|
|
129
|
+
if (f.type === 'tabs') {
|
|
130
|
+
for (const tab of f.tabs ?? []) {
|
|
131
|
+
if (!tabHasName(tab)) {
|
|
132
|
+
out.push(...serializeFields(tab.fields, ctx))
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
const perms = childPermissions(ctx.permissions, tab.name)
|
|
136
|
+
if (perms === false) continue
|
|
137
|
+
out.push({
|
|
138
|
+
name: tab.name,
|
|
139
|
+
type: 'group',
|
|
140
|
+
label: label(tab.label, toWords(tab.name), ctx),
|
|
141
|
+
fields: serializeFields(tab.fields, { ...ctx, permissions: perms }),
|
|
142
|
+
...(tab.localized ? { localized: true } : {}),
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
continue
|
|
146
|
+
}
|
|
147
|
+
if (!fieldAffectsData(f)) {
|
|
148
|
+
// row, collapsible, unnamed group: their children belong to the parent.
|
|
149
|
+
if ('fields' in f && Array.isArray(f.fields)) out.push(...serializeFields(f.fields, ctx))
|
|
150
|
+
continue
|
|
151
|
+
}
|
|
152
|
+
// Hidden from the admin (admin.hidden) or the API (hidden), disabled, virtual: not editable here either.
|
|
153
|
+
if (
|
|
154
|
+
SYSTEM_FIELDS.has(f.name) ||
|
|
155
|
+
fieldIsHiddenOrDisabled(f) ||
|
|
156
|
+
fieldIsVirtual(f) ||
|
|
157
|
+
f.admin?.hidden
|
|
158
|
+
)
|
|
159
|
+
continue
|
|
160
|
+
const perms = childPermissions(ctx.permissions, f.name)
|
|
161
|
+
if (perms === false) continue
|
|
162
|
+
|
|
163
|
+
// Field is a large union; read the optional, type-specific properties through one loose view.
|
|
164
|
+
const anyField = f as unknown as LooseField
|
|
165
|
+
const field: SchemaField = {
|
|
166
|
+
name: f.name,
|
|
167
|
+
type: f.type,
|
|
168
|
+
label: label(anyField.label, toWords(f.name), ctx),
|
|
169
|
+
}
|
|
170
|
+
if (anyField.localized) field.localized = true
|
|
171
|
+
if (anyField.required) field.required = true
|
|
172
|
+
if (anyField.hasMany) field.hasMany = true
|
|
173
|
+
if (typeof anyField.defaultValue === 'function') {
|
|
174
|
+
if (ctx.defaults) {
|
|
175
|
+
const { req, pending } = ctx.defaults
|
|
176
|
+
pending.push(
|
|
177
|
+
getDefaultValue({
|
|
178
|
+
defaultValue: anyField.defaultValue,
|
|
179
|
+
locale: req.locale ?? undefined,
|
|
180
|
+
req,
|
|
181
|
+
user: req.user,
|
|
182
|
+
})
|
|
183
|
+
.then((v) => {
|
|
184
|
+
if (isJsonSafe(v)) field.defaultValue = v
|
|
185
|
+
})
|
|
186
|
+
.catch(() => undefined),
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
} else if (isJsonSafe(anyField.defaultValue)) {
|
|
190
|
+
field.defaultValue = anyField.defaultValue
|
|
191
|
+
}
|
|
192
|
+
const description = label(anyField.admin?.description, '', ctx)
|
|
193
|
+
if (description) field.description = description
|
|
194
|
+
if (Array.isArray(anyField.options)) {
|
|
195
|
+
field.options = anyField.options.map((o: unknown) =>
|
|
196
|
+
typeof o === 'string'
|
|
197
|
+
? { label: o, value: o }
|
|
198
|
+
: {
|
|
199
|
+
label: label(
|
|
200
|
+
(o as { label?: unknown }).label,
|
|
201
|
+
String((o as { value: unknown }).value),
|
|
202
|
+
ctx,
|
|
203
|
+
),
|
|
204
|
+
value: String((o as { value: unknown }).value),
|
|
205
|
+
},
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
if (anyField.relationTo) field.relationTo = anyField.relationTo
|
|
209
|
+
for (const k of ['minRows', 'maxRows', 'min', 'max'] as const) {
|
|
210
|
+
const n = anyField[k]
|
|
211
|
+
if (typeof n === 'number') field[k] = n
|
|
212
|
+
}
|
|
213
|
+
const editor = anyField.admin?.custom?.onpage?.editor ?? anyField.custom?.onpage?.editor
|
|
214
|
+
if (typeof editor === 'string') field.editor = editor
|
|
215
|
+
const picker = anyField.admin?.date?.pickerAppearance
|
|
216
|
+
if (typeof picker === 'string') field.pickerAppearance = picker
|
|
217
|
+
const childCtx = { ...ctx, permissions: perms }
|
|
218
|
+
if (f.type === 'group' || f.type === 'array') field.fields = serializeFields(f.fields, childCtx)
|
|
219
|
+
if (f.type === 'array') {
|
|
220
|
+
const fallback = formatLabels(f.name)
|
|
221
|
+
field.labels = {
|
|
222
|
+
singular: label(anyField.labels?.singular, fallback.singular, ctx),
|
|
223
|
+
plural: label(anyField.labels?.plural, fallback.plural, ctx),
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
if (f.type === 'blocks') {
|
|
227
|
+
const refs = (f.blockReferences ?? [])
|
|
228
|
+
.map((b) => (typeof b === 'string' ? ctx.blocks?.find((x) => x.slug === b) : b))
|
|
229
|
+
.filter((b): b is Block => !!b)
|
|
230
|
+
field.blocks = [...(f.blocks ?? []), ...refs].map((b) =>
|
|
231
|
+
serializeBlock(b, blockPermissions(ctx.permissions, f.name, b.slug), ctx),
|
|
232
|
+
)
|
|
233
|
+
}
|
|
234
|
+
out.push(field)
|
|
235
|
+
}
|
|
236
|
+
return out
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Every collection a relationship or upload field in `fields` points to. */
|
|
240
|
+
export function relationTargets(fields: SchemaField[], into = new Set<string>()): Set<string> {
|
|
241
|
+
for (const f of fields) {
|
|
242
|
+
if ((f.type === 'relationship' || f.type === 'upload') && f.relationTo) {
|
|
243
|
+
for (const slug of Array.isArray(f.relationTo) ? f.relationTo : [f.relationTo]) into.add(slug)
|
|
244
|
+
}
|
|
245
|
+
if (f.fields) relationTargets(f.fields, into)
|
|
246
|
+
for (const b of f.blocks ?? []) relationTargets(b.fields, into)
|
|
247
|
+
}
|
|
248
|
+
return into
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export type { DocumentSchema }
|
package/src/tokens.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preview-link tokens: sealed (AES-GCM) and expiring, bound to one document
|
|
3
|
+
* and path. Opaque to the holder, so the link reveals nothing about the draft.
|
|
4
|
+
*/
|
|
5
|
+
import { SEAL_PURPOSE, seal, unseal } from '@payload-onpage/core'
|
|
6
|
+
|
|
7
|
+
export interface PreviewTokenPayload {
|
|
8
|
+
/** refKey of the shared document */
|
|
9
|
+
ref: string
|
|
10
|
+
/** Site path the link opens. */
|
|
11
|
+
path: string
|
|
12
|
+
locale?: string
|
|
13
|
+
/** Issued at / expires at, seconds since epoch. */
|
|
14
|
+
iat: number
|
|
15
|
+
exp: number
|
|
16
|
+
/** Unique id, for audit logs and revocation lists. */
|
|
17
|
+
jti: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function createPreviewToken(
|
|
21
|
+
payload: Pick<PreviewTokenPayload, 'ref' | 'path' | 'locale'>,
|
|
22
|
+
secret: string,
|
|
23
|
+
ttlSeconds: number,
|
|
24
|
+
now = Date.now(),
|
|
25
|
+
): Promise<{ token: string; payload: PreviewTokenPayload }> {
|
|
26
|
+
const iat = Math.floor(now / 1000)
|
|
27
|
+
const full: PreviewTokenPayload = {
|
|
28
|
+
...payload,
|
|
29
|
+
iat,
|
|
30
|
+
exp: iat + ttlSeconds,
|
|
31
|
+
jti: crypto.randomUUID(),
|
|
32
|
+
}
|
|
33
|
+
return { token: await seal(full, secret, SEAL_PURPOSE.previewLink), payload: full }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function verifyPreviewToken(
|
|
37
|
+
token: string,
|
|
38
|
+
secret: string,
|
|
39
|
+
now = Date.now(),
|
|
40
|
+
): Promise<PreviewTokenPayload | null> {
|
|
41
|
+
const p = await unseal<PreviewTokenPayload>(token, secret, SEAL_PURPOSE.previewLink)
|
|
42
|
+
if (!p || typeof p.exp !== 'number' || p.exp * 1000 <= now) return null
|
|
43
|
+
if (typeof p.path !== 'string' || typeof p.ref !== 'string' || typeof p.jti !== 'string')
|
|
44
|
+
return null
|
|
45
|
+
return p
|
|
46
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin UI strings, registered with Payload's i18n under the `onpage` namespace
|
|
3
|
+
* (so the admin language applies). Add or override languages with
|
|
4
|
+
* `i18n.translations` in your Payload config.
|
|
5
|
+
*/
|
|
6
|
+
export const translations = {
|
|
7
|
+
en: {
|
|
8
|
+
onpage: {
|
|
9
|
+
editOnPage: 'Edit on page',
|
|
10
|
+
copyPreviewLink: 'Copy preview link',
|
|
11
|
+
copyPreviewLinkHint: 'Copy a read-only link to this draft',
|
|
12
|
+
linkCopied: 'Preview link copied — valid until {{date}}',
|
|
13
|
+
linkFailed: "Couldn't create a preview link: {{message}}",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
es: {
|
|
17
|
+
onpage: {
|
|
18
|
+
editOnPage: 'Editar en la página',
|
|
19
|
+
copyPreviewLink: 'Copiar enlace de vista previa',
|
|
20
|
+
copyPreviewLinkHint: 'Copiar un enlace de solo lectura a este borrador',
|
|
21
|
+
linkCopied: 'Enlace copiado: válido hasta {{date}}',
|
|
22
|
+
linkFailed: 'No se pudo crear el enlace: {{message}}',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
fr: {
|
|
26
|
+
onpage: {
|
|
27
|
+
editOnPage: 'Modifier sur la page',
|
|
28
|
+
copyPreviewLink: 'Copier le lien d’aperçu',
|
|
29
|
+
copyPreviewLinkHint: 'Copier un lien en lecture seule vers ce brouillon',
|
|
30
|
+
linkCopied: 'Lien copié — valide jusqu’au {{date}}',
|
|
31
|
+
linkFailed: 'Impossible de créer le lien : {{message}}',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
de: {
|
|
35
|
+
onpage: {
|
|
36
|
+
editOnPage: 'Auf der Seite bearbeiten',
|
|
37
|
+
copyPreviewLink: 'Vorschaulink kopieren',
|
|
38
|
+
copyPreviewLinkHint: 'Einen schreibgeschützten Link zu diesem Entwurf kopieren',
|
|
39
|
+
linkCopied: 'Vorschaulink kopiert – gültig bis {{date}}',
|
|
40
|
+
linkFailed: 'Vorschaulink konnte nicht erstellt werden: {{message}}',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type OnPageTranslationKey = `onpage:${keyof (typeof translations)['en']['onpage']}`
|