feat(10-02): backend strings, controller messages and declarative toolbar

- phrasebook ships the backend::lang admin strings (pl, en) with CLDR
  plural maps, loads them as namespace backend, applies
  pact.HasLangOverrides trees (lang/<locale>/<namespace>/<group>.yaml)
  after every namespace, and fails activation when a backend key cannot
  convert to plural forms
- Translator.Forms, Bundle, Resolved and Has serve keys as CLDR form maps
- Public GET /lang returns every backend::lang key for the request
  locale over the fallback locale, Cache-Control no-cache
- config_list, config_form and config_relation accept a strict messages
  block; omitted keys take framework defaults, schemas serve every message
  as CLDR forms, and activation fails on a missing phrase key
- toolbar.buttons is an ordered [create, delete] list; the Winter string
  form, duplicates, unknown actions and delete without showCheckboxes fail
  at boot, and create is dropped when the controller has no form
- Form schema serves the raw Winter redirects; scaffold emits the list
  syntax; form and relation schema routes are typed in the admin OpenAPI
This commit is contained in:
Jakub Zych
2026-09-27 16:16:32 +02:00
parent fe04dbc89e
commit c87148a34f
24 changed files with 2148 additions and 45 deletions

View File

@@ -154,6 +154,36 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.Envelope-cabana_FormView": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.FormView"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_LangBundle": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.LangBundle"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.Envelope-cabana_ListSchema": { "cabana.Envelope-cabana_ListSchema": {
"properties": { "properties": {
"data": { "data": {
@@ -169,6 +199,21 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.Envelope-cabana_RelationSchema": {
"properties": {
"data": {
"$ref": "#/components/schemas/cabana.RelationSchema"
},
"meta": {
"$ref": "#/components/schemas/cabana.SuccessMeta"
}
},
"required": [
"data",
"meta"
],
"type": "object"
},
"cabana.ErrorBody": { "cabana.ErrorBody": {
"properties": { "properties": {
"code": { "code": {
@@ -200,6 +245,99 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.FormField": {
"properties": {
"attributes": {
"additionalProperties": {
"$ref": "#/components/schemas/cabana.jsonScalar"
},
"type": "object"
},
"comment": {
"type": "string"
},
"context": {
"$ref": "#/components/schemas/cabana.fieldContext"
},
"default": {
"$ref": "#/components/schemas/cabana.jsonScalar"
},
"emptyOption": {
"type": "string"
},
"label": {
"type": "string"
},
"multiple": {
"type": "boolean"
},
"name": {
"type": "string"
},
"nameFrom": {
"type": "string"
},
"options": {
"items": {
"$ref": "#/components/schemas/cabana.FormOption"
},
"type": "array"
},
"readOnly": {
"type": "boolean"
},
"relation": {
"type": "string"
},
"required": {
"type": "boolean"
},
"size": {
"type": "string"
},
"span": {
"type": "string"
},
"tab": {
"type": "string"
},
"type": {
"type": "string"
}
},
"required": [
"name",
"type"
],
"type": "object"
},
"cabana.FormMessages": {
"properties": {
"create": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleteConfirm": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleted": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"saved": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"update": {
"$ref": "#/components/schemas/cabana.MessageForms"
}
},
"required": [
"create",
"deleteConfirm",
"deleted",
"saved",
"update"
],
"type": "object"
},
"cabana.FormMeta": { "cabana.FormMeta": {
"properties": { "properties": {
"locale": { "locale": {
@@ -226,6 +364,88 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.FormRedirect": {
"properties": {
"redirect": {
"type": "string"
},
"redirectClose": {
"type": "string"
}
},
"required": [
"redirect",
"redirectClose"
],
"type": "object"
},
"cabana.FormRedirects": {
"properties": {
"create": {
"$ref": "#/components/schemas/cabana.FormRedirect"
},
"default": {
"type": "string"
},
"update": {
"$ref": "#/components/schemas/cabana.FormRedirect"
}
},
"required": [
"create",
"default",
"update"
],
"type": "object"
},
"cabana.FormView": {
"properties": {
"fields": {
"items": {
"$ref": "#/components/schemas/cabana.FormField"
},
"type": "array"
},
"messages": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.FormMessages"
}
],
"description": "Messages is the form's copy resolved in the request locale (D-13)."
},
"meta": {
"$ref": "#/components/schemas/cabana.FormMeta"
},
"modelClass": {
"type": "string"
},
"name": {
"type": "string"
},
"redirects": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.FormRedirects"
}
],
"description": "Redirects are the raw Winter config_form.yaml targets; the SPA maps\nthem onto its routes."
}
},
"required": [
"fields",
"messages",
"meta",
"redirects"
],
"type": "object"
},
"cabana.LangBundle": {
"additionalProperties": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"type": "object"
},
"cabana.ListColumn": { "cabana.ListColumn": {
"properties": { "properties": {
"key": { "key": {
@@ -337,6 +557,53 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.ListMessages": {
"properties": {
"create": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleteConfirm": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleteSelected": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"deleted": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"empty": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"emptySearch": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"emptySearchHint": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"recordCount": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"searchPrompt": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"selected": {
"$ref": "#/components/schemas/cabana.MessageForms"
}
},
"required": [
"create",
"deleteConfirm",
"deleteSelected",
"deleted",
"empty",
"emptySearch",
"emptySearchHint",
"recordCount",
"searchPrompt",
"selected"
],
"type": "object"
},
"cabana.ListMeta": { "cabana.ListMeta": {
"properties": { "properties": {
"last_page": { "last_page": {
@@ -383,6 +650,14 @@
}, },
"type": "array" "type": "array"
}, },
"messages": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.ListMessages"
}
],
"description": "Messages is the list's copy (D-13). The cached schema carries each\nphrase key as its own form; a response resolves them in its locale."
},
"meta": { "meta": {
"$ref": "#/components/schemas/cabana.FormMeta" "$ref": "#/components/schemas/cabana.FormMeta"
}, },
@@ -442,6 +717,7 @@
"bulkActions", "bulkActions",
"columns", "columns",
"filters", "filters",
"messages",
"perPageOptions", "perPageOptions",
"recordsPerPage", "recordsPerPage",
"rowActions", "rowActions",
@@ -469,6 +745,12 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.MessageForms": {
"additionalProperties": {
"type": "string"
},
"type": "object"
},
"cabana.NavigationEntry": { "cabana.NavigationEntry": {
"properties": { "properties": {
"code": { "code": {
@@ -535,6 +817,82 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.RelationColumn": {
"properties": {
"key": {
"type": "string"
},
"label": {
"type": "string"
},
"searchable": {
"type": "boolean"
},
"sortable": {
"type": "boolean"
}
},
"required": [
"key",
"label",
"searchable",
"sortable"
],
"type": "object"
},
"cabana.RelationList": {
"properties": {
"columns": {
"items": {
"$ref": "#/components/schemas/cabana.RelationColumn"
},
"type": "array"
}
},
"required": [
"columns"
],
"type": "object"
},
"cabana.RelationMessages": {
"properties": {
"candidateSearch": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"empty": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"link": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"linkHint": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"linked": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"unlinkConfirm": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"unlinkSelected": {
"$ref": "#/components/schemas/cabana.MessageForms"
},
"unlinked": {
"$ref": "#/components/schemas/cabana.MessageForms"
}
},
"required": [
"candidateSearch",
"empty",
"link",
"linkHint",
"linked",
"unlinkConfirm",
"unlinkSelected",
"unlinked"
],
"type": "object"
},
"cabana.RelationOption": { "cabana.RelationOption": {
"properties": { "properties": {
"label": { "label": {
@@ -550,6 +908,60 @@
], ],
"type": "object" "type": "object"
}, },
"cabana.RelationPanel": {
"properties": {
"list": {
"$ref": "#/components/schemas/cabana.RelationList"
},
"showSearch": {
"type": "boolean"
},
"toolbarButtons": {
"items": {
"type": "string"
},
"type": "array"
}
},
"required": [
"list",
"showSearch",
"toolbarButtons"
],
"type": "object"
},
"cabana.RelationSchema": {
"properties": {
"label": {
"type": "string"
},
"manage": {
"$ref": "#/components/schemas/cabana.RelationPanel"
},
"messages": {
"allOf": [
{
"$ref": "#/components/schemas/cabana.RelationMessages"
}
],
"description": "Messages is the relation manager's copy (D-13); the cached schema\ncarries each phrase key as its own form."
},
"name": {
"type": "string"
},
"view": {
"$ref": "#/components/schemas/cabana.RelationPanel"
}
},
"required": [
"label",
"manage",
"messages",
"name",
"view"
],
"type": "object"
},
"cabana.RowAction": { "cabana.RowAction": {
"properties": { "properties": {
"label": { "label": {
@@ -600,6 +1012,19 @@
}, },
"type": "object" "type": "object"
}, },
"cabana.fieldContext": {
"oneOf": [
{
"type": "string"
},
{
"items": {
"type": "string"
},
"type": "array"
}
]
},
"cabana.jsonScalar": { "cabana.jsonScalar": {
"nullable": true, "nullable": true,
"oneOf": [ "oneOf": [
@@ -803,6 +1228,27 @@
] ]
} }
}, },
"/lang": {
"get": {
"description": "Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/cabana.Envelope-cabana_LangBundle"
}
}
},
"description": "OK"
}
},
"summary": "Admin UI strings",
"tags": [
"admin"
]
}
},
"/navigation": { "/navigation": {
"get": { "get": {
"responses": { "responses": {
@@ -1544,7 +1990,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/cabana.SuccessEnvelope" "$ref": "#/components/schemas/cabana.Envelope-cabana_FormView"
} }
} }
}, },
@@ -1721,7 +2167,7 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"$ref": "#/components/schemas/cabana.SuccessEnvelope" "$ref": "#/components/schemas/cabana.Envelope-cabana_RelationSchema"
} }
} }
}, },

View File

@@ -204,6 +204,45 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/lang": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Admin UI strings
* @description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.
*/
get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["cabana.Envelope-cabana_LangBundle"];
};
};
};
};
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/navigation": { "/navigation": {
parameters: { parameters: {
query?: never; query?: never;
@@ -823,7 +862,7 @@ export interface paths {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["cabana.SuccessEnvelope"]; "application/json": components["schemas"]["cabana.Envelope-cabana_FormView"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -965,7 +1004,7 @@ export interface paths {
[name: string]: unknown; [name: string]: unknown;
}; };
content: { content: {
"application/json": components["schemas"]["cabana.SuccessEnvelope"]; "application/json": components["schemas"]["cabana.Envelope-cabana_RelationSchema"];
}; };
}; };
/** @description Unauthorized */ /** @description Unauthorized */
@@ -1523,10 +1562,22 @@ export interface components {
data: components["schemas"]["cabana.AdminProfile"]; data: components["schemas"]["cabana.AdminProfile"];
meta: components["schemas"]["cabana.SuccessMeta"]; meta: components["schemas"]["cabana.SuccessMeta"];
}; };
"cabana.Envelope-cabana_FormView": {
data: components["schemas"]["cabana.FormView"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_LangBundle": {
data: components["schemas"]["cabana.LangBundle"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.Envelope-cabana_ListSchema": { "cabana.Envelope-cabana_ListSchema": {
data: components["schemas"]["cabana.ListSchema"]; data: components["schemas"]["cabana.ListSchema"];
meta: components["schemas"]["cabana.SuccessMeta"]; meta: components["schemas"]["cabana.SuccessMeta"];
}; };
"cabana.Envelope-cabana_RelationSchema": {
data: components["schemas"]["cabana.RelationSchema"];
meta: components["schemas"]["cabana.SuccessMeta"];
};
"cabana.ErrorBody": { "cabana.ErrorBody": {
code: string; code: string;
details: { details: {
@@ -1537,6 +1588,34 @@ export interface components {
"cabana.ErrorEnvelope": { "cabana.ErrorEnvelope": {
error: components["schemas"]["cabana.ErrorBody"]; error: components["schemas"]["cabana.ErrorBody"];
}; };
"cabana.FormField": {
attributes?: {
[key: string]: components["schemas"]["cabana.jsonScalar"];
};
comment?: string;
context?: components["schemas"]["cabana.fieldContext"];
default?: components["schemas"]["cabana.jsonScalar"];
emptyOption?: string;
label?: string;
multiple?: boolean;
name: string;
nameFrom?: string;
options?: components["schemas"]["cabana.FormOption"][];
readOnly?: boolean;
relation?: string;
required?: boolean;
size?: string;
span?: string;
tab?: string;
type: string;
};
"cabana.FormMessages": {
create: components["schemas"]["cabana.MessageForms"];
deleteConfirm: components["schemas"]["cabana.MessageForms"];
deleted: components["schemas"]["cabana.MessageForms"];
saved: components["schemas"]["cabana.MessageForms"];
update: components["schemas"]["cabana.MessageForms"];
};
"cabana.FormMeta": { "cabana.FormMeta": {
locale: string; locale: string;
}; };
@@ -1544,6 +1623,31 @@ export interface components {
label: string; label: string;
value: components["schemas"]["cabana.jsonScalar"]; value: components["schemas"]["cabana.jsonScalar"];
}; };
"cabana.FormRedirect": {
redirect: string;
redirectClose: string;
};
"cabana.FormRedirects": {
create: components["schemas"]["cabana.FormRedirect"];
default: string;
update: components["schemas"]["cabana.FormRedirect"];
};
"cabana.FormView": {
fields: components["schemas"]["cabana.FormField"][];
/** @description Messages is the form's copy resolved in the request locale (D-13). */
messages: components["schemas"]["cabana.FormMessages"];
meta: components["schemas"]["cabana.FormMeta"];
modelClass?: string;
name?: string;
/**
* @description Redirects are the raw Winter config_form.yaml targets; the SPA maps
* them onto its routes.
*/
redirects: components["schemas"]["cabana.FormRedirects"];
};
"cabana.LangBundle": {
[key: string]: components["schemas"]["cabana.MessageForms"];
};
"cabana.ListColumn": { "cabana.ListColumn": {
key: string; key: string;
label: string; label: string;
@@ -1575,6 +1679,18 @@ export interface components {
trueValue?: components["schemas"]["cabana.jsonScalar"]; trueValue?: components["schemas"]["cabana.jsonScalar"];
type: string; type: string;
}; };
"cabana.ListMessages": {
create: components["schemas"]["cabana.MessageForms"];
deleteConfirm: components["schemas"]["cabana.MessageForms"];
deleteSelected: components["schemas"]["cabana.MessageForms"];
deleted: components["schemas"]["cabana.MessageForms"];
empty: components["schemas"]["cabana.MessageForms"];
emptySearch: components["schemas"]["cabana.MessageForms"];
emptySearchHint: components["schemas"]["cabana.MessageForms"];
recordCount: components["schemas"]["cabana.MessageForms"];
searchPrompt: components["schemas"]["cabana.MessageForms"];
selected: components["schemas"]["cabana.MessageForms"];
};
"cabana.ListMeta": { "cabana.ListMeta": {
last_page: number; last_page: number;
page: number; page: number;
@@ -1586,6 +1702,11 @@ export interface components {
columns: components["schemas"]["cabana.ListColumn"][]; columns: components["schemas"]["cabana.ListColumn"][];
defaultSort?: components["schemas"]["cabana.ListSort"]; defaultSort?: components["schemas"]["cabana.ListSort"];
filters: components["schemas"]["cabana.ListFilter"][]; filters: components["schemas"]["cabana.ListFilter"][];
/**
* @description Messages is the list's copy (D-13). The cached schema carries each
* phrase key as its own form; a response resolves them in its locale.
*/
messages: components["schemas"]["cabana.ListMessages"];
meta?: components["schemas"]["cabana.FormMeta"]; meta?: components["schemas"]["cabana.FormMeta"];
modelClass?: string; modelClass?: string;
noRecordsMessage?: string; noRecordsMessage?: string;
@@ -1606,6 +1727,9 @@ export interface components {
column: string; column: string;
direction: string; direction: string;
}; };
"cabana.MessageForms": {
[key: string]: string;
};
"cabana.NavigationEntry": { "cabana.NavigationEntry": {
code: string; code: string;
controller: string; controller: string;
@@ -1623,10 +1747,45 @@ export interface components {
[key: string]: components["schemas"]["cabana.RelationOption"][]; [key: string]: components["schemas"]["cabana.RelationOption"][];
}; };
}; };
"cabana.RelationColumn": {
key: string;
label: string;
searchable: boolean;
sortable: boolean;
};
"cabana.RelationList": {
columns: components["schemas"]["cabana.RelationColumn"][];
};
"cabana.RelationMessages": {
candidateSearch: components["schemas"]["cabana.MessageForms"];
empty: components["schemas"]["cabana.MessageForms"];
link: components["schemas"]["cabana.MessageForms"];
linkHint: components["schemas"]["cabana.MessageForms"];
linked: components["schemas"]["cabana.MessageForms"];
unlinkConfirm: components["schemas"]["cabana.MessageForms"];
unlinkSelected: components["schemas"]["cabana.MessageForms"];
unlinked: components["schemas"]["cabana.MessageForms"];
};
"cabana.RelationOption": { "cabana.RelationOption": {
label: string; label: string;
value: number; value: number;
}; };
"cabana.RelationPanel": {
list: components["schemas"]["cabana.RelationList"];
showSearch: boolean;
toolbarButtons: string[];
};
"cabana.RelationSchema": {
label: string;
manage: components["schemas"]["cabana.RelationPanel"];
/**
* @description Messages is the relation manager's copy (D-13); the cached schema
* carries each phrase key as its own form.
*/
messages: components["schemas"]["cabana.RelationMessages"];
name: string;
view: components["schemas"]["cabana.RelationPanel"];
};
"cabana.RowAction": { "cabana.RowAction": {
label?: string; label?: string;
name: string; name: string;
@@ -1643,6 +1802,7 @@ export interface components {
per_page?: number; per_page?: number;
total?: number; total?: number;
}; };
"cabana.fieldContext": string | string[];
"cabana.jsonScalar": (string | number | boolean) | null; "cabana.jsonScalar": (string | number | boolean) | null;
}; };
responses: never; responses: never;

View File

@@ -119,6 +119,20 @@ func AdminLogin() {}
// @Router /auth/refresh [post] // @Router /auth/refresh [post]
func AdminRefresh() {} func AdminRefresh() {}
// LangBundle is the public string bundle: full backend::lang key to CLDR
// forms (D-20).
type LangBundle map[string]MessageForms
// AdminLang documents GET /lang.
//
// @Summary Admin UI strings
// @Description Every backend::lang key as CLDR plural forms for the Accept-Language locale, over the fallback locale's keys. Public: the login screen loads it before signing in. meta.locale is the locale the bundle resolved to.
// @Tags admin
// @Produce json
// @Success 200 {object} Envelope[LangBundle]
// @Router /lang [get]
func AdminLang() {}
// AdminLogout documents POST /auth/logout. // AdminLogout documents POST /auth/logout.
// //
// @Summary Admin logout // @Summary Admin logout
@@ -233,7 +247,7 @@ func AdminListSchema() {}
// @Param vendor path string true "Vendor" // @Param vendor path string true "Vendor"
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Success 200 {object} SuccessEnvelope // @Success 200 {object} Envelope[FormView]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope
@@ -250,7 +264,7 @@ func AdminFormSchema() {}
// @Param plugin path string true "Plugin" // @Param plugin path string true "Plugin"
// @Param controller path string true "Controller" // @Param controller path string true "Controller"
// @Param name path string true "Relation name" // @Param name path string true "Relation name"
// @Success 200 {object} SuccessEnvelope // @Success 200 {object} Envelope[RelationSchema]
// @Failure 401 {object} ErrorEnvelope // @Failure 401 {object} ErrorEnvelope
// @Failure 403 {object} ErrorEnvelope // @Failure 403 {object} ErrorEnvelope
// @Failure 404 {object} ErrorEnvelope // @Failure 404 {object} ErrorEnvelope

View File

@@ -38,12 +38,13 @@ var (
) )
type formConfigDocument struct { type formConfigDocument struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Form string `yaml:"form"` Form string `yaml:"form"`
ModelClass string `yaml:"modelClass"` ModelClass string `yaml:"modelClass"`
DefaultRedirect string `yaml:"defaultRedirect"` DefaultRedirect string `yaml:"defaultRedirect"`
Create *formRedirects `yaml:"create"` Create *formRedirects `yaml:"create"`
Update *formRedirects `yaml:"update"` Update *formRedirects `yaml:"update"`
Messages *formMessageKeys `yaml:"messages"`
} }
type formRedirects struct { type formRedirects struct {
@@ -102,11 +103,22 @@ func CompileForm(pluginID string, ctl pact.AdminController, fsys fs.FS) (*FormSc
if err := requireDropdownProvider(ctl, fields); err != nil { if err := requireDropdownProvider(ctl, fields); err != nil {
return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err) return nil, bootErr(pluginID, ctl.ID(), fieldsPath, err)
} }
return &FormSchema{ schema := &FormSchema{
Name: doc.Name, Name: doc.Name,
ModelClass: doc.ModelClass, ModelClass: doc.ModelClass,
Fields: fields, Fields: fields,
}, nil redirects: FormRedirects{Default: doc.DefaultRedirect},
}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
if doc.Create != nil {
schema.redirects.Create = FormRedirect{Redirect: doc.Create.Redirect, RedirectClose: doc.Create.RedirectClose}
}
if doc.Update != nil {
schema.redirects.Update = FormRedirect{Redirect: doc.Update.Redirect, RedirectClose: doc.Update.RedirectClose}
}
return schema, nil
} }
// Localize copies the cached schema and resolves display strings for one request. // Localize copies the cached schema and resolves display strings for one request.
@@ -141,6 +153,8 @@ func (s *FormSchema) Localize(ctx context.Context, tr *phrasebook.Translator, pr
Name: translateKey(ctx, tr, s.Name), Name: translateKey(ctx, tr, s.Name),
ModelClass: s.ModelClass, ModelClass: s.ModelClass,
Fields: fields, Fields: fields,
Messages: localizeMessages[formMessageKeys, FormMessages](ctx, tr, s.formMessageKeySet()),
Redirects: s.redirects,
Meta: FormMeta{Locale: locale}, Meta: FormMeta{Locale: locale},
}, nil }, nil
} }

View File

@@ -96,6 +96,11 @@ func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
if app == nil { if app == nil {
return nil, errors.New("cabana: app is nil") return nil, errors.New("cabana: app is nil")
} }
if tr, ok := app.Lookup[*phrasebook.Translator](); ok && tr != nil {
if err := validateMessageKeys(reg, tr); err != nil {
return nil, err
}
}
guards, ok := app.Lookup[*bouncer.Registry]() guards, ok := app.Lookup[*bouncer.Registry]()
if !ok || guards == nil { if !ok || guards == nil {
guards = bouncer.NewRegistry() guards = bouncer.NewRegistry()
@@ -183,6 +188,10 @@ func (s *service) mount(r pact.Router) {
g.Post("/login", s.login, throttle) g.Post("/login", s.login, throttle)
g.Post("/refresh", requireAjax(s.refresh)) g.Post("/refresh", requireAjax(s.refresh))
}) })
// The string bundle is public: the login screen needs it before auth.
r.GroupRaw(api, nil, func(g pact.Router) {
g.Get("/lang", s.langBundle)
})
r.GroupRaw(api, []string{"backend"}, func(g pact.Router) { r.GroupRaw(api, []string{"backend"}, func(g pact.Router) {
g.Post("/auth/logout", requireAjax(s.logout)) g.Post("/auth/logout", requireAjax(s.logout))
g.Get("/auth/me", s.me) g.Get("/auth/me", s.me)

24
cabana/lang.go Normal file
View File

@@ -0,0 +1,24 @@
package cabana
import (
"net/http"
)
// adminLangPrefix is the only namespace the public bundle serves (D-20).
const adminLangPrefix = "backend::lang."
// langBundle serves GET {prefix}/api/v1/lang: every backend::lang key as CLDR
// forms for the request locale, with the fallback locale's keys underneath.
// It is public (the login screen needs it) and never includes another
// namespace.
func (s *service) langBundle(w http.ResponseWriter, r *http.Request) {
tr := s.translator()
locale := schemaLocale(r.Context(), tr)
data := map[string]map[string]string{}
if tr != nil {
locale = tr.Resolved(locale, adminLangPrefix)
data = tr.Bundle(locale, adminLangPrefix)
}
w.Header().Set("Cache-Control", "no-cache")
WriteData(w, http.StatusOK, data, map[string]any{"locale": locale})
}

View File

@@ -13,6 +13,7 @@ import (
"git.golem15.com/golem15/summercms/phrasebook" "git.golem15.com/golem15/summercms/phrasebook"
"git.golem15.com/golem15/summercms/towel" "git.golem15.com/golem15/summercms/towel"
"github.com/goccy/go-yaml" "github.com/goccy/go-yaml"
"github.com/goccy/go-yaml/ast"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -37,6 +38,7 @@ type listDocument struct {
DefaultSort *listSortDocument `yaml:"defaultSort"` DefaultSort *listSortDocument `yaml:"defaultSort"`
Toolbar *listToolbar `yaml:"toolbar"` Toolbar *listToolbar `yaml:"toolbar"`
Filter string `yaml:"filter"` Filter string `yaml:"filter"`
Messages *listMessageKeys `yaml:"messages"`
} }
type listSortDocument struct { type listSortDocument struct {
@@ -45,7 +47,7 @@ type listSortDocument struct {
} }
type listToolbar struct { type listToolbar struct {
Buttons string `yaml:"buttons"` Buttons toolbarButtons `yaml:"buttons"`
Search *struct { Search *struct {
Prompt string `yaml:"prompt"` Prompt string `yaml:"prompt"`
Mode string `yaml:"mode"` Mode string `yaml:"mode"`
@@ -123,7 +125,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode)) return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("unsupported search mode %s", mode))
} }
} }
buttons, err := compileToolbarButtons(doc.Toolbar) buttons, err := compileToolbarButtons(doc.Toolbar, doc.ShowCheckboxes)
if err != nil { if err != nil {
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err) return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
} }
@@ -154,7 +156,7 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
return nil, err return nil, err
} }
} }
return &ListSchema{ schema := &ListSchema{
Title: doc.Title, Title: doc.Title,
ModelClass: doc.ModelClass, ModelClass: doc.ModelClass,
RecordURL: doc.RecordURL, RecordURL: doc.RecordURL,
@@ -173,7 +175,13 @@ func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSc
Filters: filters, Filters: filters,
RowActions: rowActions, RowActions: rowActions,
BulkActions: bulk, BulkActions: bulk,
}, nil }
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, schema.listMessageKeySet())
schema.Messages = &keys
return schema, nil
} }
func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string) ([]ListColumn, error) { func compileColumns(pluginID string, ctl pact.AdminController, fsys fs.FS, colPath string) ([]ListColumn, error) {
@@ -270,16 +278,58 @@ func compilePageOptions(recordsPerPage int, declared []int) ([]int, error) {
return options, nil return options, nil
} }
func compileToolbarButtons(toolbar *listToolbar) ([]string, error) { // toolbarButtons is the declarative toolbar.buttons list (D-14): built-in
if toolbar == nil || strings.TrimSpace(toolbar.Buttons) == "" { // actions in display order. A scalar (Winter's partial name) is rejected
// with a pointer at the list syntax.
type toolbarButtons struct {
items []string
}
// toolbarActions are the built-in toolbar actions; custom actions are Phase 10.1.
var toolbarActions = map[string]struct{}{"create": {}, "delete": {}}
func (b *toolbarButtons) UnmarshalYAML(node ast.Node) error {
node = unwrapNode(node)
switch n := node.(type) {
case nil, *ast.NullNode:
b.items = nil
return nil
case *ast.SequenceNode:
values := sequenceValues(n)
items := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, item := range values {
action, err := nodeString(unwrapNode(item))
if err != nil {
return fmt.Errorf("toolbar.buttons entries must be action names (create, delete)")
}
if _, ok := toolbarActions[action]; !ok {
return fmt.Errorf("toolbar.buttons: unsupported action %s (want create or delete)", action)
}
if _, dup := seen[action]; dup {
return fmt.Errorf("toolbar.buttons: duplicate action %s", action)
}
seen[action] = struct{}{}
items = append(items, action)
}
b.items = items
return nil
default:
return fmt.Errorf("toolbar.buttons must be a list of actions (create, delete); the Winter partial %q is not supported", nodeText(node))
}
}
func compileToolbarButtons(toolbar *listToolbar, showCheckboxes bool) ([]string, error) {
if toolbar == nil || len(toolbar.Buttons.items) == 0 {
return []string{}, nil return []string{}, nil
} }
switch toolbar.Buttons { out := append([]string(nil), toolbar.Buttons.items...)
case "list_toolbar": for _, action := range out {
return []string{"create"}, nil if action == "delete" && !showCheckboxes {
default: return nil, fmt.Errorf("toolbar.buttons: delete needs showCheckboxes: true")
return nil, fmt.Errorf("unsupported action %s", toolbar.Buttons) }
} }
return out, nil
} }
func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) { func compileDefaultSort(doc *listSortDocument, columns []ListColumn) (*ListSort, error) {
@@ -391,6 +441,8 @@ func (s *ListSchema) Localize(ctx context.Context, tr *phrasebook.Translator) (*
out.Filters = localizeListFilters(ctx, tr, s.Filters) out.Filters = localizeListFilters(ctx, tr, s.Filters)
out.RowActions = localizeRowActions(ctx, tr, s.RowActions) out.RowActions = localizeRowActions(ctx, tr, s.RowActions)
out.BulkActions = localizeBulkActions(ctx, tr, s.BulkActions) out.BulkActions = localizeBulkActions(ctx, tr, s.BulkActions)
messages := localizeMessages[listMessageKeys, ListMessages](ctx, tr, s.listMessageKeySet())
out.Messages = &messages
out.Meta = &FormMeta{Locale: locale} out.Meta = &FormMeta{Locale: locale}
return &out, nil return &out, nil
} }

View File

@@ -32,12 +32,16 @@ defaultSort:
column: name column: name
direction: asc direction: asc
toolbar: toolbar:
buttons: list_toolbar buttons: [create, delete]
search: search:
prompt: backend::lang.list.search_prompt prompt: backend::lang.list.search_prompt
` `
const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}]}` const allColumnsJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordUrl":"acme/demo/widgets/update/:id","noRecordsMessage":"backend::lang.list.no_records","recordsPerPage":20,"perPageOptions":[20,50,100],"showSearch":true,"showSetup":true,"showCheckboxes":true,"showSorting":true,"searchTerm":"search","searchPrompt":"backend::lang.list.search_prompt","defaultSort":{"column":"name","direction":"asc"},"toolbarButtons":["create","delete"],"columns":[{"key":"name","label":"demo.lang.name","searchable":true,"sortable":true,"type":"text"},{"key":"created_at","label":"demo.lang.created","searchable":false,"sortable":true,"type":"datetime"},{"key":"active","label":"demo.lang.active","searchable":false,"sortable":false,"type":"switch"},{"key":"genre","label":"demo.lang.genre","searchable":true,"sortable":false,"relation":"genre","select":"name"}],"filters":[],"rowActions":[{"name":"update","label":"backend::lang.form.update","url":"acme/demo/widgets/update/:id"}],"bulkActions":[{"name":"delete","label":"backend::lang.list.delete_selected"}],` + defaultListMessagesJSON + `}`
// defaultListMessagesJSON is a compiled list's messages block when the YAML
// declares none: every key is a framework default phrase key (D-13).
const defaultListMessagesJSON = `"messages":{"recordCount":{"other":"backend::lang.messages.list.record_count"},"create":{"other":"backend::lang.messages.list.create"},"searchPrompt":{"other":"backend::lang.list.search_prompt"},"empty":{"other":"backend::lang.list.no_records"},"emptySearch":{"other":"backend::lang.messages.list.empty_search"},"emptySearchHint":{"other":"backend::lang.messages.list.empty_search_hint"},"selected":{"other":"backend::lang.messages.list.selected"},"deleteSelected":{"other":"backend::lang.list.delete_selected"},"deleteConfirm":{"other":"backend::lang.messages.list.delete_confirm"},"deleted":{"other":"backend::lang.messages.list.deleted"}}`
func TestListSchemaCompile(t *testing.T) { func TestListSchemaCompile(t *testing.T) {
columns := readListFixture(t, "testdata/list/all_columns.yaml") columns := readListFixture(t, "testdata/list/all_columns.yaml")
@@ -52,7 +56,7 @@ func TestListSchemaCompile(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
`"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`, `"searchable":false`, `"sortable":false`, `"type":"datetime"`, `"type":"switch"`,
`"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`, `"relation":"genre"`, `"select":"name"`, `"searchTerm":"search"`, `"showSetup":true`,
`"perPageOptions":[20,50,100]`, `"toolbarButtons":["create"]`, `"filters":[]`, `"perPageOptions":[20,50,100]`, `"toolbarButtons":["create","delete"]`, `"filters":[]`,
} { } {
if !strings.Contains(got, want) { if !strings.Contains(got, want) {
t.Fatalf("compiled list missing %s:\n%s", want, got) t.Fatalf("compiled list missing %s:\n%s", want, got)
@@ -68,7 +72,7 @@ noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 20 recordsPerPage: 20
showCheckboxes: true showCheckboxes: true
toolbar: toolbar:
buttons: list_toolbar buttons: [create, delete]
search: search:
prompt: backend::lang.list.search_prompt prompt: backend::lang.list.search_prompt
` `
@@ -98,7 +102,7 @@ modelClass: Widget
recordsPerPage: 20 recordsPerPage: 20
` `
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n") got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns: {}\n")
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[]}` want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
if got != want { if got != want {
t.Fatalf("empty list =\n%s\nwant\n%s", got, want) t.Fatalf("empty list =\n%s\nwant\n%s", got, want)
} }
@@ -115,7 +119,7 @@ modelClass: Widget
recordsPerPage: 20 recordsPerPage: 20
` `
got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n") got := compileListJSON(t, schemaController{model: "Widget"}, config, "columns:\n name:\n label: Name\n searchable: true\n")
want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[]}` want := `{"modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
if got != want { if got != want {
t.Fatalf("single list =\n%s\nwant\n%s", got, want) t.Fatalf("single list =\n%s\nwant\n%s", got, want)
} }
@@ -217,7 +221,7 @@ func TestListSchemaRejects(t *testing.T) {
}, },
{ {
name: "unsupported action", name: "unsupported action",
config: strings.Replace(allColumnsConfig, "buttons: list_toolbar", "buttons: drop_database", 1), config: strings.Replace(allColumnsConfig, "buttons: [create, delete]", "buttons: [create, drop_database]", 1),
columns: columns, columns: columns,
want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "drop_database"}, want: []string{"acme.demo", "acme.demo.widgets", "config_list.yaml", "drop_database"},
}, },
@@ -307,7 +311,7 @@ const filterColumns = `columns:
searchable: true searchable: true
` `
const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[]}` const allFiltersJSON = `{"title":"demo.lang.widgets","modelClass":"Widget","recordsPerPage":20,"perPageOptions":[20],"showSearch":false,"showSetup":false,"showCheckboxes":false,"showSorting":true,"searchTerm":"search","toolbarButtons":[],"columns":[{"key":"name","label":"Name","searchable":true,"sortable":true}],"filters":[{"name":"activated","label":"demo.lang.activated","type":"switch","column":"active","options":[{"value":true,"label":"demo.lang.yes"},{"value":false,"label":"demo.lang.no"}]},{"name":"created","label":"demo.lang.created_range","type":"daterange","column":"created_at"},{"name":"grouped","label":"demo.lang.grouped","type":"scope","scope":"filterByGroup","modelClass":"Group","nameFrom":"name"}],"rowActions":[],"bulkActions":[],` + defaultListMessagesJSON + `}`
func TestListSchemaFilter(t *testing.T) { func TestListSchemaFilter(t *testing.T) {
filters := readListFixture(t, "testdata/list/all_filters.yaml") filters := readListFixture(t, "testdata/list/all_filters.yaml")

257
cabana/messages.go Normal file
View File

@@ -0,0 +1,257 @@
package cabana
import (
"context"
"encoding/json"
"fmt"
"reflect"
"sort"
"git.golem15.com/golem15/summercms/phrasebook"
)
// MessageForms is one controller message resolved to its CLDR plural forms
// (D-13). "other" is always present; the SPA picks a form with
// Intl.PluralRules and interpolates :count, :name and :term (D-24).
type MessageForms map[string]string
// MarshalJSON keeps an unset message an object, never null.
func (m MessageForms) MarshalJSON() ([]byte, error) {
if m == nil {
return []byte("{}"), nil
}
return json.Marshal(map[string]string(m))
}
// listMessageKeys is the config_list.yaml messages block: phrase keys.
type listMessageKeys struct {
RecordCount string `yaml:"recordCount"`
Create string `yaml:"create"`
SearchPrompt string `yaml:"searchPrompt"`
Empty string `yaml:"empty"`
EmptySearch string `yaml:"emptySearch"`
EmptySearchHint string `yaml:"emptySearchHint"`
Selected string `yaml:"selected"`
DeleteSelected string `yaml:"deleteSelected"`
DeleteConfirm string `yaml:"deleteConfirm"`
Deleted string `yaml:"deleted"`
}
// ListMessages is a list schema's copy, every key resolved (same field order
// as listMessageKeys).
type ListMessages struct {
RecordCount MessageForms `json:"recordCount"`
Create MessageForms `json:"create"`
SearchPrompt MessageForms `json:"searchPrompt"`
Empty MessageForms `json:"empty"`
EmptySearch MessageForms `json:"emptySearch"`
EmptySearchHint MessageForms `json:"emptySearchHint"`
Selected MessageForms `json:"selected"`
DeleteSelected MessageForms `json:"deleteSelected"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
Deleted MessageForms `json:"deleted"`
}
// formMessageKeys is the config_form.yaml messages block: phrase keys.
type formMessageKeys struct {
Create string `yaml:"create"`
Update string `yaml:"update"`
Saved string `yaml:"saved"`
DeleteConfirm string `yaml:"deleteConfirm"`
Deleted string `yaml:"deleted"`
}
// FormMessages is a form's copy, every key resolved.
type FormMessages struct {
Create MessageForms `json:"create"`
Update MessageForms `json:"update"`
Saved MessageForms `json:"saved"`
DeleteConfirm MessageForms `json:"deleteConfirm"`
Deleted MessageForms `json:"deleted"`
}
// relationMessageKeys is one config_relation.yaml relation's messages block.
type relationMessageKeys struct {
Link string `yaml:"link"`
LinkHint string `yaml:"linkHint"`
CandidateSearch string `yaml:"candidateSearch"`
Linked string `yaml:"linked"`
UnlinkSelected string `yaml:"unlinkSelected"`
UnlinkConfirm string `yaml:"unlinkConfirm"`
Unlinked string `yaml:"unlinked"`
Empty string `yaml:"empty"`
}
// RelationMessages is a relation manager's copy, every key resolved.
type RelationMessages struct {
Link MessageForms `json:"link"`
LinkHint MessageForms `json:"linkHint"`
CandidateSearch MessageForms `json:"candidateSearch"`
Linked MessageForms `json:"linked"`
UnlinkSelected MessageForms `json:"unlinkSelected"`
UnlinkConfirm MessageForms `json:"unlinkConfirm"`
Unlinked MessageForms `json:"unlinked"`
Empty MessageForms `json:"empty"`
}
// Framework defaults (D-13): every omitted key falls back to backend::lang.
var (
listMessageDefaults = listMessageKeys{
RecordCount: "backend::lang.messages.list.record_count",
Create: "backend::lang.messages.list.create",
SearchPrompt: "backend::lang.list.search_prompt",
Empty: "backend::lang.list.no_records",
EmptySearch: "backend::lang.messages.list.empty_search",
EmptySearchHint: "backend::lang.messages.list.empty_search_hint",
Selected: "backend::lang.messages.list.selected",
DeleteSelected: "backend::lang.list.delete_selected",
DeleteConfirm: "backend::lang.messages.list.delete_confirm",
Deleted: "backend::lang.messages.list.deleted",
}
formMessageDefaults = formMessageKeys{
Create: "backend::lang.messages.form.create",
Update: "backend::lang.messages.form.update",
Saved: "backend::lang.messages.form.saved",
DeleteConfirm: "backend::lang.messages.form.delete_confirm",
Deleted: "backend::lang.messages.form.deleted",
}
relationMessageDefaults = relationMessageKeys{
Link: "backend::lang.messages.relation.link",
LinkHint: "backend::lang.messages.relation.link_hint",
CandidateSearch: "backend::lang.messages.relation.candidate_search",
Linked: "backend::lang.messages.relation.linked",
UnlinkSelected: "backend::lang.messages.relation.unlink_selected",
UnlinkConfirm: "backend::lang.messages.relation.unlink_confirm",
Unlinked: "backend::lang.messages.relation.unlinked",
Empty: "backend::lang.messages.relation.empty",
}
)
// withDefaults fills every empty key of keys from defaults.
func withDefaults[K any](keys, defaults K) K {
out := keys
dst := reflect.ValueOf(&out).Elem()
src := reflect.ValueOf(defaults)
for i := 0; i < dst.NumField(); i++ {
if dst.Field(i).String() == "" {
dst.Field(i).SetString(src.Field(i).String())
}
}
return out
}
// messageKeyPairs lists (yaml name, phrase key) for every key, in field order.
func messageKeyPairs[K any](keys K) [][2]string {
v := reflect.ValueOf(keys)
t := v.Type()
out := make([][2]string, 0, t.NumField())
for i := 0; i < t.NumField(); i++ {
out = append(out, [2]string{t.Field(i).Tag.Get("yaml"), v.Field(i).String()})
}
return out
}
// localizeMessages resolves each key of K into the same-index MessageForms
// field of M. Without a translator, or for a key that cannot convert, the
// form is {"other": text} where text is the translation or the key itself.
func localizeMessages[K any, M any](ctx context.Context, tr *phrasebook.Translator, keys K) M {
var out M
dst := reflect.ValueOf(&out).Elem()
src := reflect.ValueOf(keys)
locale := schemaLocale(ctx, tr)
for i := 0; i < src.NumField(); i++ {
key := src.Field(i).String()
dst.Field(i).Set(reflect.ValueOf(messageForms(tr, locale, key)))
}
return out
}
func messageForms(tr *phrasebook.Translator, locale, key string) MessageForms {
if key == "" {
return MessageForms{"other": ""}
}
if tr == nil {
return MessageForms{"other": key}
}
if forms, ok := tr.Forms(locale, key); ok {
return MessageForms(forms)
}
return MessageForms{"other": tr.GetIn(locale, key, nil)}
}
// listMessageKeySet is the list's resolved key set: declared keys over the
// framework defaults, with searchPrompt and empty defaulting to the list's own
// toolbar.search.prompt and noRecordsMessage when those are set.
func (s *ListSchema) listMessageKeySet() listMessageKeys {
defaults := listMessageDefaults
if s.SearchPrompt != "" {
defaults.SearchPrompt = s.SearchPrompt
}
if s.NoRecordsMessage != "" {
defaults.Empty = s.NoRecordsMessage
}
return withDefaults(s.messageKeys, defaults)
}
// formMessageKeySet is the form's resolved key set; create defaults to the
// config_form.yaml name when it is set.
func (s *FormSchema) formMessageKeySet() formMessageKeys {
defaults := formMessageDefaults
if s.Name != "" {
defaults.Create = s.Name
}
return withDefaults(s.messageKeys, defaults)
}
func (s *RelationSchema) relationMessageKeySet() relationMessageKeys {
return withDefaults(s.messageKeys, relationMessageDefaults)
}
// validateMessageKeys fails activation when a controller's messages block (or
// a framework default) names a phrase key no locale defines (D-13). Defaults
// taken from the list's own search prompt, empty message or the form name are
// Winter strings that may be literal text, so they are not checked.
func validateMessageKeys(reg *Registry, tr *phrasebook.Translator) error {
if reg == nil || tr == nil {
return nil
}
ids := make([]string, 0, len(reg.byID))
for id := range reg.byID {
ids = append(ids, id)
}
sort.Strings(ids)
check := func(pluginID, id, file string, pairs [][2]string) error {
for _, pair := range pairs {
if pair[1] != "" && !tr.Has(pair[1]) {
return bootErr(pluginID, id, file, fmt.Errorf("messages.%s names missing phrase key %s", pair[0], pair[1]))
}
}
return nil
}
for _, id := range ids {
cc := reg.byID[id]
if cc.List != nil {
if err := check(cc.PluginID, id, "config_list.yaml", messageKeyPairs(withDefaults(cc.List.messageKeys, listMessageDefaults))); err != nil {
return err
}
}
if cc.Form != nil {
if err := check(cc.PluginID, id, "config_form.yaml", messageKeyPairs(withDefaults(cc.Form.messageKeys, formMessageDefaults))); err != nil {
return err
}
}
names := make([]string, 0, len(cc.Relations))
for name := range cc.Relations {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
if schema := cc.Relations[name].Schema; schema != nil {
if err := check(cc.PluginID, id, "config_relation.yaml", messageKeyPairs(schema.relationMessageKeySet())); err != nil {
return fmt.Errorf("relation %s: %w", name, err)
}
}
}
}
return nil
}

354
cabana/messages_test.go Normal file
View File

@@ -0,0 +1,354 @@
package cabana
import (
"context"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/phrasebook"
"git.golem15.com/golem15/summercms/towel"
)
type langPlugin struct {
id string
lang fs.FS
}
func (p langPlugin) ID() string { return p.id }
func (p langPlugin) LangFS() fs.FS { return p.lang }
// messagesTranslator activates phrasebook with the framework backend strings
// and an acme.demo catalog.
func messagesTranslator(t *testing.T) *phrasebook.Translator {
t.Helper()
app := backpack.New(nil)
demo := fstest.MapFS{
"lang/pl/lang.yaml": {Data: []byte(`widgets:
count:
one: ":count widżet"
few: ":count widżety"
many: ":count widżetów"
other: ":count widżetu"
create: Nowy widżet
saved: Widżet „:name” zapisany
link: Dodaj edytora
`)},
"lang/en/lang.yaml": {Data: []byte(`widgets:
count:
one: ":count widget"
other: ":count widgets"
create: New widget
saved: Widget “:name” saved
link: Add editor
`)},
}
if err := phrasebook.Activate(app, []langPlugin{{id: "acme.demo", lang: demo}}); err != nil {
t.Fatal(err)
}
tr, ok := app.Lookup[*phrasebook.Translator]()
if !ok || tr == nil {
t.Fatal("translator missing")
}
return tr
}
const messagesListConfig = `modelClass: Widget
list: ~/plugins/acme/demo/models/widget/columns.yaml
recordsPerPage: 20
showCheckboxes: true
toolbar:
buttons: [create, delete]
search:
prompt: backend::lang.list.search_prompt
messages:
recordCount: acme.demo::lang.widgets.count
create: acme.demo::lang.widgets.create
`
func localeCtx(locale string) context.Context {
return towel.WithLocale(context.Background(), locale)
}
func TestPhase10Messages(t *testing.T) {
tr := messagesTranslator(t)
schema, err := CompileList("acme.demo", schemaController{}, listFS(messagesListConfig, "columns: {}\n"))
if err != nil {
t.Fatal(err)
}
pl, err := schema.Localize(localeCtx("pl"), tr)
if err != nil {
t.Fatal(err)
}
m := pl.Messages
if m == nil {
t.Fatal("localized list has no messages")
}
if len(m.RecordCount) != 4 || m.RecordCount["few"] != ":count widżety" || m.RecordCount["many"] != ":count widżetów" {
t.Fatalf("plural recordCount=%v", m.RecordCount)
}
if m.Create["other"] != "Nowy widżet" {
t.Fatalf("plugin create=%v", m.Create)
}
for name, got := range map[string]MessageForms{
"deleteConfirm": m.DeleteConfirm,
"searchPrompt": m.SearchPrompt,
"empty": m.Empty,
"deleteSelected": m.DeleteSelected,
"selected": m.Selected,
} {
if got["other"] == "" || strings.Contains(got["other"], "::") {
t.Fatalf("default %s did not resolve: %v", name, got)
}
}
if m.DeleteConfirm["other"] != "Usunąć zaznaczone (:count)?" || m.SearchPrompt["other"] != "Szukaj…" {
t.Fatalf("framework defaults: deleteConfirm=%v searchPrompt=%v", m.DeleteConfirm, m.SearchPrompt)
}
if len(m.Deleted) != 4 || m.Deleted["one"] != "Usunięto :count rekord" {
t.Fatalf("default plural deleted=%v", m.Deleted)
}
en, err := schema.Localize(localeCtx("en"), tr)
if err != nil {
t.Fatal(err)
}
if en.Messages.RecordCount["one"] != ":count widget" || len(en.Messages.RecordCount) != 2 || en.Messages.Create["other"] != "New widget" {
t.Fatalf("en messages=%+v", en.Messages)
}
raw, err := json.Marshal(pl)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"messages":{"recordCount":{`) {
t.Fatalf("served list JSON has no messages: %s", raw)
}
if schema.Messages.RecordCount["other"] != "acme.demo::lang.widgets.count" {
t.Fatalf("cached schema lost its keys: %v", schema.Messages.RecordCount)
}
t.Run("form", func(t *testing.T) {
config := `name: acme.demo::lang.widgets.create
form: ~/plugins/acme/demo/models/widget/fields.yaml
modelClass: Widget
defaultRedirect: acme/demo/widgets
create:
redirect: acme/demo/widgets/update/:id
redirectClose: acme/demo/widgets
update:
redirect: acme/demo/widgets
redirectClose: acme/demo/widgets
messages:
saved: acme.demo::lang.widgets.saved
`
form, err := CompileForm("acme.demo", schemaController{}, formFS(config, "fields:\n name:\n type: text\n"))
if err != nil {
t.Fatal(err)
}
view, err := form.Localize(localeCtx("pl"), tr, nil)
if err != nil {
t.Fatal(err)
}
if view.Messages.Saved["other"] != "Widżet „:name” zapisany" || view.Messages.Create["other"] != "Nowy widżet" || view.Messages.Update["other"] != "Edycja rekordu" {
t.Fatalf("form messages=%+v", view.Messages)
}
want := FormRedirects{Default: "acme/demo/widgets",
Create: FormRedirect{Redirect: "acme/demo/widgets/update/:id", RedirectClose: "acme/demo/widgets"},
Update: FormRedirect{Redirect: "acme/demo/widgets", RedirectClose: "acme/demo/widgets"}}
if view.Redirects != want {
t.Fatalf("redirects=%+v", view.Redirects)
}
raw, err := view.CanonicalJSON()
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(raw), `"redirects":{"default":"acme/demo/widgets"`) || !strings.Contains(string(raw), `"saved":{"other":`) {
t.Fatalf("form view JSON=%s", raw)
}
})
t.Run("relation", func(t *testing.T) {
yaml := strings.Replace(validRelationYAML, "editors:\n label: demo.editors\n", "editors:\n label: demo.editors\n messages:\n link: acme.demo::lang.widgets.link\n", 1)
ctl := relationTestController{}
fsys := relationTestFS(yaml)
form, err := CompileForm("acme.demo", ctl, fsys)
if err != nil {
t.Fatal(err)
}
relations, err := compileRelations("acme.demo", ctl, fsys, form)
if err != nil {
t.Fatal(err)
}
view := relations["editors"].Schema.Localize(localeCtx("pl"), tr)
if view.Messages.Link["other"] != "Dodaj edytora" || len(view.Messages.Linked) != 4 || view.Messages.Linked["few"] != "Dołączono :count rekordy" {
t.Fatalf("relation messages=%+v", view.Messages)
}
})
t.Run("unknown key fails at boot", func(t *testing.T) {
list := strings.Replace(messagesListConfig, " create: acme.demo::lang.widgets.create\n", " create: acme.demo::lang.widgets.create\n bogusKey: x\n", 1)
if _, err := CompileList("acme.demo", schemaController{}, listFS(list, "columns: {}\n")); err == nil || !strings.Contains(err.Error(), "bogusKey") || !strings.Contains(err.Error(), "config_list.yaml") {
t.Fatalf("list err=%v", err)
}
form := "form: ~/plugins/acme/demo/models/widget/fields.yaml\nmodelClass: Widget\nmessages:\n toast: x\n"
if _, err := CompileForm("acme.demo", schemaController{}, formFS(form, "fields: {}\n")); err == nil || !strings.Contains(err.Error(), "toast") {
t.Fatalf("form err=%v", err)
}
yaml := strings.Replace(validRelationYAML, " label: demo.editors\n", " label: demo.editors\n messages:\n bogusKey: x\n", 1)
ctl := relationTestController{}
fsys := relationTestFS(yaml)
compiled, _ := CompileForm("acme.demo", ctl, fsys)
if _, err := compileRelations("acme.demo", ctl, fsys, compiled); err == nil || !strings.Contains(err.Error(), "bogusKey") {
t.Fatalf("relation err=%v", err)
}
})
t.Run("missing phrase key fails at boot", func(t *testing.T) {
list := strings.Replace(messagesListConfig, "acme.demo::lang.widgets.count", "acme.demo::lang.widgets.missing", 1)
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(list, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
err = validateMessageKeys(reg, tr)
if err == nil || !strings.Contains(err.Error(), "messages.recordCount") || !strings.Contains(err.Error(), "acme.demo::lang.widgets.missing") || !strings.Contains(err.Error(), "acme.demo.widgets") {
t.Fatalf("err=%v", err)
}
ok, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(messagesListConfig, "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
if err := validateMessageKeys(ok, tr); err != nil {
t.Fatalf("declared keys failed: %v", err)
}
if err := validateMessageKeys(reg, nil); err != nil {
t.Fatalf("no translator must skip the check: %v", err)
}
})
}
func TestPhase10Toolbar(t *testing.T) {
base := "modelClass: Widget\nlist: ~/plugins/acme/demo/models/widget/columns.yaml\nshowCheckboxes: true\ntoolbar:\n buttons: BUTTONS\n"
compile := func(buttons string, checkboxes bool) (*ListSchema, error) {
config := strings.Replace(base, "BUTTONS", buttons, 1)
if !checkboxes {
config = strings.Replace(config, "showCheckboxes: true\n", "", 1)
}
return CompileList("acme.demo", schemaController{}, listFS(config, "columns: {}\n"))
}
for _, tc := range []struct {
buttons string
want []string
}{
{"[create, delete]", []string{"create", "delete"}},
{"[delete, create]", []string{"delete", "create"}},
{"[create]", []string{"create"}},
{"[]", []string{}},
{"\n - delete\n - create", []string{"delete", "create"}},
} {
schema, err := compile(tc.buttons, true)
if err != nil {
t.Fatalf("%s: %v", tc.buttons, err)
}
if strings.Join(schema.ToolbarButtons, ",") != strings.Join(tc.want, ",") {
t.Fatalf("%s compiled %v want %v", tc.buttons, schema.ToolbarButtons, tc.want)
}
}
for _, tc := range []struct {
buttons string
checkboxes bool
want string
}{
{"list_toolbar", true, "toolbar.buttons must be a list"},
{"list_toolbar", true, `"list_toolbar" is not supported`},
{"[create, delete]", false, "showCheckboxes"},
{"[create, create]", true, "duplicate action create"},
{"[create, export]", true, "unsupported action export"},
{"[{create: true}]", true, "toolbar.buttons"},
} {
_, err := compile(tc.buttons, tc.checkboxes)
if err == nil || !strings.Contains(err.Error(), tc.want) || !strings.Contains(err.Error(), "acme.demo.widgets") {
t.Fatalf("%s err=%v want %q", tc.buttons, err, tc.want)
}
}
// A controller without a compiled form cannot create: create is dropped.
reg, err := compileRegistry([]controllerRef{{plugin: formPlugin{fsys: listFS(strings.Replace(base, "BUTTONS", "[create, delete]", 1), "columns: {}\n")}, ctl: schemaController{}}})
if err != nil {
t.Fatal(err)
}
cc, _ := reg.Get("acme.demo.widgets")
if strings.Join(cc.List.ToolbarButtons, ",") != "delete" {
t.Fatalf("form-less toolbar=%v", cc.List.ToolbarButtons)
}
}
func TestPhase10Bundle(t *testing.T) {
tr := messagesTranslator(t)
app := backpack.New(nil)
if err := app.Publish(tr); err != nil {
t.Fatal(err)
}
svc := &service{app: app}
call := func(locale string) (map[string]map[string]string, string, *httptest.ResponseRecorder) {
t.Helper()
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
if locale != "" {
req = req.WithContext(towel.WithLocale(req.Context(), locale))
}
rec := httptest.NewRecorder()
svc.langBundle(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
}
var body struct {
Data map[string]map[string]string `json:"data"`
Meta struct {
Locale string `json:"locale"`
} `json:"meta"`
}
dec := json.NewDecoder(strings.NewReader(rec.Body.String()))
dec.DisallowUnknownFields()
if err := dec.Decode(&body); err != nil {
t.Fatal(err)
}
return body.Data, body.Meta.Locale, rec
}
pl, locale, rec := call("pl")
if locale != "pl" || rec.Header().Get("Cache-Control") != "no-cache" {
t.Fatalf("locale=%q cache=%q", locale, rec.Header().Get("Cache-Control"))
}
if pl["backend::lang.form.save"]["other"] != "Zapisz" || len(pl["backend::lang.list.results"]) != 4 {
t.Fatalf("pl bundle save=%v results=%v", pl["backend::lang.form.save"], pl["backend::lang.list.results"])
}
if len(pl) < 50 {
t.Fatalf("pl bundle has only %d keys", len(pl))
}
for key := range pl {
if !strings.HasPrefix(key, "backend::lang.") {
t.Fatalf("bundle leaked a key outside backend::lang: %s", key)
}
}
if strings.Contains(rec.Body.String(), "acme.demo::") || strings.Contains(rec.Body.String(), "lagoon::") {
t.Fatalf("bundle leaked another namespace: %s", rec.Body.String())
}
en, locale, _ := call("en-GB")
if locale != "en" || en["backend::lang.form.save"]["other"] != "Save" {
t.Fatalf("en-GB locale=%q save=%v", locale, en["backend::lang.form.save"])
}
fallback, locale, _ := call("de")
if locale != "en" || fallback["backend::lang.form.save"]["other"] != "Save" {
t.Fatalf("de falls back to en: locale=%q save=%v", locale, fallback["backend::lang.form.save"])
}
// No translator: an empty bundle, still an envelope.
empty := &service{app: backpack.New(nil)}
req := httptest.NewRequest(http.MethodGet, adminAPI("/lang"), nil)
out := httptest.NewRecorder()
empty.langBundle(out, req)
if out.Code != http.StatusOK || !strings.Contains(out.Body.String(), `"data":{}`) {
t.Fatalf("no translator status=%d body=%s", out.Code, out.Body.String())
}
}

View File

@@ -71,8 +71,9 @@ func TestPhase09ContractInventory(t *testing.T) {
if !ok200 && !ok201 { if !ok200 && !ok201 {
t.Fatalf("%s has no 200 or 201 response", key) t.Fatalf("%s has no 200 or 201 response", key)
} }
if public[key] { if route.public {
if _, ok := op.Responses["401"]; !ok { // Login and refresh answer 401; the public string bundle does not.
if _, ok := op.Responses["401"]; public[key] && !ok {
t.Fatalf("%s has no 401 response", key) t.Fatalf("%s has no 401 response", key)
} }
continue continue

View File

@@ -77,6 +77,10 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if form == nil && list != nil {
// Without a compiled form there is nothing to create (D-14).
list.ToolbarButtons = withoutAction(list.ToolbarButtons, "create")
}
compiled := &CompiledController{ compiled := &CompiledController{
PluginID: item.plugin.ID(), PluginID: item.plugin.ID(),
Controller: item.ctl, Controller: item.ctl,
@@ -93,6 +97,16 @@ func compileRegistry(items []controllerRef) (*Registry, error) {
return &Registry{byID: byID}, nil return &Registry{byID: byID}, nil
} }
func withoutAction(actions []string, drop string) []string {
out := make([]string, 0, len(actions))
for _, action := range actions {
if action != drop {
out = append(out, action)
}
}
return out
}
func compileContributions(reg *Registry, plugins []party.Plugin) error { func compileContributions(reg *Registry, plugins []party.Plugin) error {
if reg == nil { if reg == nil {
return fmt.Errorf("cabana: registry is nil") return fmt.Errorf("cabana: registry is nil")

View File

@@ -61,6 +61,11 @@ type RelationSchema struct {
Label string `json:"label"` Label string `json:"label"`
View RelationPanel `json:"view"` View RelationPanel `json:"view"`
Manage RelationPanel `json:"manage"` Manage RelationPanel `json:"manage"`
// Messages is the relation manager's copy (D-13); the cached schema
// carries each phrase key as its own form.
Messages *RelationMessages `json:"messages"`
messageKeys relationMessageKeys
} }
// CompiledRelation combines trusted YAML with model-owned metadata. // CompiledRelation combines trusted YAML with model-owned metadata.
@@ -100,6 +105,10 @@ type RelationMutationResult struct {
type RelationService struct{ DB *gorm.DB } type RelationService struct{ DB *gorm.DB }
func (s RelationSchema) MarshalJSON() ([]byte, error) { func (s RelationSchema) MarshalJSON() ([]byte, error) {
if s.Messages == nil {
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, s.relationMessageKeySet())
s.Messages = &keys
}
type alias RelationSchema type alias RelationSchema
out := alias(s) out := alias(s)
ensureRelationArrays(&out.View) ensureRelationArrays(&out.View)
@@ -125,6 +134,8 @@ func (s *RelationSchema) Localize(ctx context.Context, tr *phrasebook.Translator
out.Label = translateKey(ctx, tr, s.Label) out.Label = translateKey(ctx, tr, s.Label)
out.View = localizeRelationPanel(ctx, tr, s.View) out.View = localizeRelationPanel(ctx, tr, s.View)
out.Manage = localizeRelationPanel(ctx, tr, s.Manage) out.Manage = localizeRelationPanel(ctx, tr, s.Manage)
messages := localizeMessages[relationMessageKeys, RelationMessages](ctx, tr, s.relationMessageKeySet())
out.Messages = &messages
return &out return &out
} }
@@ -144,9 +155,10 @@ type relationRoot struct {
} }
type relationDocument struct { type relationDocument struct {
Label string `yaml:"label"` Label string `yaml:"label"`
View relationPanelDocument `yaml:"view"` View relationPanelDocument `yaml:"view"`
Manage relationPanelDocument `yaml:"manage"` Manage relationPanelDocument `yaml:"manage"`
Messages *relationMessageKeys `yaml:"messages"`
} }
type relationPanelDocument struct { type relationPanelDocument struct {
@@ -236,8 +248,14 @@ func compileRelations(pluginID string, ctl pact.AdminController, fsys fs.FS, for
} }
orderRelationColumns(raw, &view) orderRelationColumns(raw, &view)
orderRelationColumns(raw, &manage) orderRelationColumns(raw, &manage)
schema := &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage}
if doc.Messages != nil {
schema.messageKeys = *doc.Messages
}
keys := localizeMessages[relationMessageKeys, RelationMessages](context.Background(), nil, schema.relationMessageKeySet())
schema.Messages = &keys
out[name] = &CompiledRelation{ out[name] = &CompiledRelation{
Schema: &RelationSchema{Name: name, Label: doc.Label, View: view, Manage: manage}, Schema: schema,
Contract: contract, Contract: contract,
RequiredPermissions: append([]string(nil), requiredOf(ctl)...), RequiredPermissions: append([]string(nil), requiredOf(ctl)...),
} }

View File

@@ -1,6 +1,9 @@
package cabana package cabana
import "encoding/json" import (
"context"
"encoding/json"
)
// ListSort is the compiled default sort. Direction is exactly asc or desc. // ListSort is the compiled default sort. Direction is exactly asc or desc.
type ListSort struct { type ListSort struct {
@@ -68,11 +71,20 @@ type ListSchema struct {
Filters []ListFilter `json:"filters"` Filters []ListFilter `json:"filters"`
RowActions []RowAction `json:"rowActions"` RowActions []RowAction `json:"rowActions"`
BulkActions []BulkAction `json:"bulkActions"` BulkActions []BulkAction `json:"bulkActions"`
Meta *FormMeta `json:"meta,omitempty"` // Messages is the list's copy (D-13). The cached schema carries each
// phrase key as its own form; a response resolves them in its locale.
Messages *ListMessages `json:"messages"`
Meta *FormMeta `json:"meta,omitempty"`
messageKeys listMessageKeys
} }
// MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null. // MarshalJSON keeps omitted collections as arrays so a partial copy cannot emit null.
func (s ListSchema) MarshalJSON() ([]byte, error) { func (s ListSchema) MarshalJSON() ([]byte, error) {
if s.Messages == nil {
keys := localizeMessages[listMessageKeys, ListMessages](context.Background(), nil, s.listMessageKeySet())
s.Messages = &keys
}
type alias ListSchema type alias ListSchema
out := alias(s) out := alias(s)
if out.PerPageOptions == nil { if out.PerPageOptions == nil {
@@ -102,6 +114,9 @@ type FormSchema struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
ModelClass string `json:"modelClass,omitempty"` ModelClass string `json:"modelClass,omitempty"`
Fields []FormField `json:"fields"` Fields []FormField `json:"fields"`
messageKeys formMessageKeys
redirects FormRedirects
} }
// FormView is one request's localized form, including the locale actually used. // FormView is one request's localized form, including the locale actually used.
@@ -109,7 +124,25 @@ type FormView struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
ModelClass string `json:"modelClass,omitempty"` ModelClass string `json:"modelClass,omitempty"`
Fields []FormField `json:"fields"` Fields []FormField `json:"fields"`
Meta FormMeta `json:"meta"` // Messages is the form's copy resolved in the request locale (D-13).
Messages FormMessages `json:"messages"`
// Redirects are the raw Winter config_form.yaml targets; the SPA maps
// them onto its routes.
Redirects FormRedirects `json:"redirects"`
Meta FormMeta `json:"meta"`
}
// FormRedirects are config_form.yaml defaultRedirect, create.* and update.*.
type FormRedirects struct {
Default string `json:"default"`
Create FormRedirect `json:"create"`
Update FormRedirect `json:"update"`
}
// FormRedirect is one Winter redirect pair.
type FormRedirect struct {
Redirect string `json:"redirect"`
RedirectClose string `json:"redirectClose"`
} }
// FormMeta reports the locale selected for a schema response. // FormMeta reports the locale selected for a schema response.

View File

@@ -34,6 +34,7 @@ const nestedGetRoute = "GET /{vendor}/{plugin}/{controller}/{id}/{segment}/{name
var phase09Routes = []adminRoute{ var phase09Routes = []adminRoute{
{key: "POST /auth/login", public: true}, {key: "POST /auth/login", public: true},
{key: "POST /auth/refresh", public: true}, {key: "POST /auth/refresh", public: true},
{key: "GET /lang", public: true},
{key: "POST /auth/logout"}, {key: "POST /auth/logout"},
{key: "GET /auth/me"}, {key: "GET /auth/me"},
{key: "GET /navigation"}, {key: "GET /navigation"},

View File

@@ -978,6 +978,8 @@ func TestMakeAdminControllerLayout(t *testing.T) {
for _, text := range []string{ for _, text := range []string{
"modelClass: Albums", "modelClass: Albums",
"list: ~/plugins/golem15/demo/models/albums/columns.yaml", "list: ~/plugins/golem15/demo/models/albums/columns.yaml",
"buttons: [create]",
"prompt: backend::lang.list.search_prompt",
} { } {
if !bytes.Contains(listSrc, []byte(text)) { if !bytes.Contains(listSrc, []byte(text)) {
t.Fatalf("config_list.yaml missing %s:\n%s", text, listSrc) t.Fatalf("config_list.yaml missing %s:\n%s", text, listSrc)

View File

@@ -123,7 +123,10 @@ update:
modelClass: {{.Ident}} modelClass: {{.Ident}}
title: {{.Ident}} title: {{.Ident}}
recordsPerPage: 20 recordsPerPage: 20
showSearch: true toolbar:
buttons: [create]
search:
prompt: backend::lang.list.search_prompt
{{end}} {{end}}
{{define "fields.yaml"}}fields: {{define "fields.yaml"}}fields:

View File

@@ -268,6 +268,15 @@ type HasLang interface {
LangFS() fs.FS LangFS() fs.FS
} }
// HasLangOverrides is implemented by plugins (usually the app plugin) that
// replace or add translations of any loaded namespace, including the
// framework's backend::lang admin strings, and may add locales (D-20).
// Layout: lang/<locale>/<namespace>/<group>.yaml, e.g.
// lang/pl/backend/lang.yaml.
type HasLangOverrides interface {
LangOverridesFS() fs.FS
}
// HasMailTemplates is implemented by plugins that ship Winter-shaped mail // HasMailTemplates is implemented by plugins that ship Winter-shaped mail
// templates and layout aliases. MailTemplatesFS contains views/mail assets; // templates and layout aliases. MailTemplatesFS contains views/mail assets;
// MailTemplates lists dotted template names; MailLayouts maps a short layout // MailTemplates lists dotted template names; MailLayouts maps a short layout

View File

@@ -0,0 +1,119 @@
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
# English equivalents of the Polish copy; plural texts are CLDR maps (one,
# other), never pipe strings. Placeholders use :count, :name, :term (D-24).
auth:
title: Welcome back
login: Login or email
password: Password
invalid: Invalid login details
submit: Sign in
logout: Sign out
session_expired: Your session has expired. Please sign in again.
nav:
plugins: Plugins
sections: Sections
breadcrumbs: Breadcrumbs
empty: No sections are available.
settings: Settings
collapse: Collapse menu
expand: Expand menu
user_menu: User menu
page:
not_found: Page not found.
list:
search_prompt: Search…
no_records: There are no records in this view.
no_results: 0 results
loading: Loading…
load_failed: The list could not be loaded.
delete_selected: Delete selected
prev_page: Previous
next_page: Next
records_per_page: Per page
column_switch_true: "Yes"
column_switch_false: "No"
column_created: Created
column_updated: Updated
pagination_range: ":from–:to of :total"
results:
one: ":count result"
other: ":count results"
empty_value: "—"
clear_search: Clear search
select_all: Select all on this page
select_row: Select row
sort_ascending: Sort ascending
sort_descending: Sort descending
form:
save: Save
save_and_close: Save and close
cancel: Cancel
delete: Delete
none: — none —
update: Edit
create: Create
return_to_list: Back to list
close: Close
confirm: Confirm
saving: Saving…
deleting: Deleting…
add: Add
remove_item: "Remove: :name"
add_item: Add…
select_placeholder: Select…
loading_options: Loading…
no_options: No results
unsupported_field: "Unsupported field type: :type"
error_title: Could not save.
error_fields:
one: "Fix :count field marked below."
other: "Fix :count fields marked below."
error_generic: The changes could not be saved. Please try again.
unsaved_confirm: You have unsaved changes. Discard them?
tab_errors:
one: ":count error"
other: ":count errors"
relation:
add: Add
link: Link
unlink: Unlink
remove: Remove
search: Search…
add_count: "Add (:count)"
candidates_range: ":from–:to of :total"
settings:
title: Settings
empty: No settings are available.
saved: Settings saved
messages:
list:
record_count:
one: ":count record"
other: ":count records"
create: New record
empty_search: Nothing found
empty_search_hint: No record matches “:term”. Try another search.
selected: ":count selected"
delete_confirm: "Delete the selected (:count)?"
deleted:
one: "Deleted :count record"
other: "Deleted :count records"
form:
create: New record
update: Edit record
saved: Saved
delete_confirm: Delete this record?
deleted: Record deleted
relation:
link: Add
link_hint: Choose the records to link.
candidate_search: Search…
linked:
one: "Linked :count record"
other: "Linked :count records"
unlink_selected: Unlink selected
unlink_confirm: "Unlink the selected (:count)?"
unlinked:
one: "Unlinked :count record"
other: "Unlinked :count records"
empty: No linked records.

View File

@@ -0,0 +1,135 @@
# Framework admin strings (D-20), resolved as backend::lang.<group>.<key>.
# Winter backend key names are kept where Winter has them; values follow the
# admin design copy (D-06). Plural texts are CLDR maps (one, few, many,
# other), never pipe strings, so every key converts to the SPA's plural forms.
# Placeholders use :count, :name, :term and friends (D-24).
auth:
title: Witaj ponownie
login: Login lub e-mail
password: Hasło
invalid: Nieprawidłowe dane logowania
submit: Zaloguj
logout: Wyloguj
session_expired: Sesja wygasła. Zaloguj się ponownie.
nav:
plugins: Wtyczki
sections: Sekcje
breadcrumbs: Ścieżka nawigacji
empty: Brak dostępnych sekcji.
settings: Ustawienia
collapse: Zwiń menu
expand: Rozwiń menu
user_menu: Menu użytkownika
page:
not_found: Nie znaleziono strony.
list:
search_prompt: Szukaj…
no_records: Brak rekordów w tym widoku.
no_results: 0 wyników
loading: Wczytywanie…
load_failed: Nie udało się wczytać listy.
delete_selected: Usuń zaznaczone
prev_page: Poprzednia
next_page: Następna
records_per_page: Na stronę
column_switch_true: Tak
column_switch_false: Nie
column_created: Utworzono
column_updated: Zaktualizowano
pagination_range: ":from–:to z :total"
results:
one: ":count wynik"
few: ":count wyniki"
many: ":count wyników"
other: ":count wyniku"
empty_value: "—"
clear_search: Wyczyść wyszukiwanie
select_all: Zaznacz wszystkie na stronie
select_row: Zaznacz wiersz
sort_ascending: Sortuj rosnąco
sort_descending: Sortuj malejąco
form:
save: Zapisz
save_and_close: Zapisz i zamknij
cancel: Anuluj
delete: Usuń
none: — brak —
update: Edytuj
create: Utwórz
return_to_list: Wróć do listy
close: Zamknij
confirm: Potwierdź
saving: Zapisywanie…
deleting: Usuwanie…
add: Dodaj
remove_item: "Usuń: :name"
add_item: Dodaj…
select_placeholder: Wybierz…
loading_options: Wczytywanie…
no_options: Brak wyników
unsupported_field: "Nieobsługiwany typ pola: :type"
error_title: Nie udało się zapisać.
error_fields:
one: "Popraw :count pole oznaczone poniżej."
few: "Popraw :count pola oznaczone poniżej."
many: "Popraw :count pól oznaczonych poniżej."
other: "Popraw :count pola oznaczonego poniżej."
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
tab_errors:
one: ":count błąd"
few: ":count błędy"
many: ":count błędów"
other: ":count błędu"
relation:
add: Dodaj
link: Dołącz
unlink: Odłącz
remove: Usuń
search: Szukaj…
add_count: "Dodaj (:count)"
candidates_range: ":from–:to z :total"
settings:
title: Ustawienia
empty: Brak dostępnych ustawień.
saved: Zapisano ustawienia
messages:
list:
record_count:
one: ":count rekord"
few: ":count rekordy"
many: ":count rekordów"
other: ":count rekordu"
create: Nowy rekord
empty_search: Nic nie znaleziono
empty_search_hint: Żaden rekord nie pasuje do frazy „:term”. Spróbuj innej frazy.
selected: "Zaznaczono :count"
delete_confirm: "Usunąć zaznaczone (:count)?"
deleted:
one: "Usunięto :count rekord"
few: "Usunięto :count rekordy"
many: "Usunięto :count rekordów"
other: "Usunięto :count rekordu"
form:
create: Nowy rekord
update: Edycja rekordu
saved: Zapisano
delete_confirm: Usunąć ten rekord?
deleted: Usunięto rekord
relation:
link: Dodaj
link_hint: Wybierz rekordy, które chcesz dołączyć.
candidate_search: Szukaj…
linked:
one: "Dołączono :count rekord"
few: "Dołączono :count rekordy"
many: "Dołączono :count rekordów"
other: "Dołączono :count rekordu"
unlink_selected: Odłącz zaznaczone
unlink_confirm: "Odłączyć zaznaczone (:count)?"
unlinked:
one: "Odłączono :count rekord"
few: "Odłączono :count rekordy"
many: "Odłączono :count rekordów"
other: "Odłączono :count rekordu"
empty: Brak powiązanych rekordów.

View File

@@ -1,6 +1,22 @@
package phrasebook package phrasebook
import "embed" import (
"embed"
"io/fs"
)
//go:embed lang //go:embed lang
var systemLangFS embed.FS var systemLangFS embed.FS
//go:embed backend/lang
var backendLangFS embed.FS
// backendLang is the framework admin string tree (D-20), laid out as
// lang/<locale>/lang.yaml so its keys resolve as backend::lang.<group>.<key>.
func backendLang() fs.FS {
sub, err := fs.Sub(backendLangFS, "backend")
if err != nil {
panic(err)
}
return sub
}

View File

@@ -19,6 +19,8 @@ type Catalog struct {
messages map[string]map[string]entry messages map[string]map[string]entry
// namespace (plugin ID) -> owning plugin ID // namespace (plugin ID) -> owning plugin ID
namespaces map[string]string namespaces map[string]string
// replace lets put overwrite an existing key; only Override sets it.
replace bool
} }
type entry struct { type entry struct {
@@ -221,7 +223,7 @@ func (c *Catalog) put(pluginID, file, locale, key string, e entry) error {
byKey = make(map[string]entry) byKey = make(map[string]entry)
c.messages[locale] = byKey c.messages[locale] = byKey
} }
if _, exists := byKey[key]; exists { if _, exists := byKey[key]; exists && !c.replace {
return catalogErrorf(pluginID, file, key, "duplicate key") return catalogErrorf(pluginID, file, key, "duplicate key")
} }
byKey[key] = e byKey[key] = e
@@ -240,6 +242,106 @@ func (c *Catalog) lookup(locale, key string) (entry, bool) {
return e, ok return e, ok
} }
// Override applies owner's override tree, laid out as
// lang/<locale>/<namespace>/<group>.yaml, over the loaded catalog. It may
// replace existing keys of a loaded namespace and add locales; a malformed
// path or an unknown namespace fails.
func (c *Catalog) Override(owner string, fsys fs.FS) error {
if c == nil {
return fmt.Errorf("phrasebook: catalog is nil")
}
if fsys == nil {
return nil
}
var files []string
err := fs.WalkDir(fsys, ".", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
files = append(files, p)
}
return nil
})
if err != nil {
return catalogErrorf(owner, "", "", "walk lang overrides: %s", err.Error())
}
sort.Strings(files)
c.replace = true
defer func() { c.replace = false }()
for _, file := range files {
locale, namespace, group, err := parseOverridePath(file)
if err != nil {
return catalogErrorf(owner, file, "", "%s", err.Error())
}
if _, loaded := c.namespaces[namespace]; !loaded {
return catalogErrorf(owner, file, "", "override names unknown namespace %s", namespace)
}
raw, err := fs.ReadFile(fsys, file)
if err != nil {
return catalogErrorf(owner, file, "", "read: %s", err.Error())
}
if err := c.loadFile(namespace, file, locale, group, raw); err != nil {
return fmt.Errorf("phrasebook: override by %s: %w", owner, err)
}
}
return nil
}
func parseOverridePath(p string) (locale, namespace, group string, err error) {
bad := fmt.Errorf("malformed override path (want lang/<locale>/<namespace>/<group>.yaml)")
if strings.Contains(p, "\\") || path.Clean(p) != p {
return "", "", "", bad
}
parts := strings.Split(p, "/")
if len(parts) != 4 || parts[0] != "lang" || !strings.HasSuffix(parts[3], ".yaml") {
return "", "", "", bad
}
locale, namespace, group = parts[1], parts[2], strings.TrimSuffix(parts[3], ".yaml")
if !validLocale(locale) || !validNamespace(namespace) || !validGroup(group) {
return "", "", "", bad
}
return locale, namespace, group, nil
}
func validNamespace(s string) bool {
if s == "" || s == "." || s == ".." || strings.HasPrefix(s, ".") || strings.HasSuffix(s, ".") {
return false
}
for _, r := range s {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' || r == '-' || r == '.' {
continue
}
return false
}
return true
}
// checkForms fails when a key under prefix cannot convert to CLDR forms, so
// strings served to the admin SPA never need Laravel exact or range pipes.
func (c *Catalog) checkForms(prefix string) error {
locales := make([]string, 0, len(c.messages))
for locale := range c.messages {
locales = append(locales, locale)
}
sort.Strings(locales)
for _, locale := range locales {
keys := make([]string, 0)
for key := range c.messages[locale] {
if strings.HasPrefix(key, prefix) {
keys = append(keys, key)
}
}
sort.Strings(keys)
for _, key := range keys {
if _, ok := entryForms(c.messages[locale][key]); !ok {
return fmt.Errorf("phrasebook: %s key %s cannot convert to CLDR plural forms (use a plural map, not an exact or range pipe)", locale, key)
}
}
}
return nil
}
func parseLangPath(p string) (locale, group string, err error) { func parseLangPath(p string) (locale, group string, err error) {
p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/")) p = path.Clean("/" + strings.ReplaceAll(p, "\\", "/"))
p = strings.TrimPrefix(p, "/") p = strings.TrimPrefix(p, "/")

199
phrasebook/phase10_test.go Normal file
View File

@@ -0,0 +1,199 @@
package phrasebook
import (
"io/fs"
"os"
"path/filepath"
"regexp"
"runtime"
"sort"
"strings"
"testing"
"testing/fstest"
"git.golem15.com/golem15/summercms/backpack"
)
func TestPhase10Forms(t *testing.T) {
cat := NewCatalog()
if err := cat.Load("acme.demo", fstest.MapFS{
"lang/en/lang.yaml": {Data: []byte(`plain: Hello :name
plural:
one: ":count apple"
other: ":count apples"
pipe: ":count apple|:count apples"
exact: "{0} No apples|[1,*] :count apples"
`)},
"lang/pl/lang.yaml": {Data: []byte(`pipe: ":count jabłko|:count jabłka|:count jabłek|:count jabłka"
`)},
}); err != nil {
t.Fatal(err)
}
tr := NewTranslator(cat, Options{Locale: "en", Fallback: "en"})
check := func(locale, key string, want map[string]string) {
t.Helper()
got, ok := tr.Forms(locale, key)
if !ok {
t.Fatalf("%s %s: not convertible", locale, key)
}
if len(got) != len(want) {
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
}
for cat, text := range want {
if got[cat] != text {
t.Fatalf("%s %s = %v want %v", locale, key, got, want)
}
}
}
check("en", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
check("en", "acme.demo::lang.plural", map[string]string{"one": ":count apple", "other": ":count apples"})
check("en", "acme.demo::lang.pipe", map[string]string{"one": ":count apple", "other": ":count apples"})
check("pl", "acme.demo::lang.pipe", map[string]string{"one": ":count jabłko", "few": ":count jabłka", "many": ":count jabłek", "other": ":count jabłka"})
// pl falls back to en for a key it does not define.
check("pl", "acme.demo::lang.plain", map[string]string{"other": "Hello :name"})
if forms, ok := tr.Forms("en", "acme.demo::lang.exact"); ok {
t.Fatalf("exact/range pipe converted to %v", forms)
}
if forms, ok := tr.Forms("en", "acme.demo::lang.missing"); ok {
t.Fatalf("missing key converted to %v", forms)
}
got, ok := tr.Forms("en", "acme.demo::lang.plural")
if !ok {
t.Fatal("plural not convertible")
}
got["one"] = "mutated"
if again, _ := tr.Forms("en", "acme.demo::lang.plural"); again["one"] != ":count apple" {
t.Fatal("Forms returned the catalog's own map")
}
}
type overridePlugin struct {
id string
lang fs.FS
overrides fs.FS
}
func (p overridePlugin) ID() string { return p.id }
func (p overridePlugin) LangFS() fs.FS { return p.lang }
func (p overridePlugin) LangOverridesFS() fs.FS { return p.overrides }
func activateWith(t *testing.T, plugins ...overridePlugin) (*Translator, error) {
t.Helper()
app := backpack.New(nil)
if err := Activate(app, plugins); err != nil {
return nil, err
}
tr, ok := app.Lookup[*Translator]()
if !ok || tr == nil {
t.Fatal("translator was not published")
}
return tr, nil
}
func TestPhase10LangOverride(t *testing.T) {
demo := fstest.MapFS{"lang/en/lang.yaml": {Data: []byte("title: Demo\n")}}
tr, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: fstest.MapFS{
"lang/pl/backend/lang.yaml": {Data: []byte("form:\n save: Zachowaj\n extra: Dodatkowy\n")},
"lang/de/backend/lang.yaml": {Data: []byte("form:\n save: Speichern\n")},
"lang/en/acme.demo/lang.yaml": {Data: []byte("title: Demo app\n")},
}})
if err != nil {
t.Fatalf("activate: %v", err)
}
for _, tc := range []struct{ locale, key, want string }{
{"pl", "backend::lang.form.save", "Zachowaj"},
{"pl", "backend::lang.form.extra", "Dodatkowy"},
{"pl", "backend::lang.form.cancel", "Anuluj"},
{"de", "backend::lang.form.save", "Speichern"},
{"en", "backend::lang.form.save", "Save"},
{"en", "acme.demo::lang.title", "Demo app"},
} {
if got := tr.GetIn(tc.locale, tc.key, nil); got != tc.want {
t.Fatalf("%s %s = %q want %q", tc.locale, tc.key, got, tc.want)
}
}
if bundle := tr.Bundle("de", "backend::lang."); bundle["backend::lang.form.save"]["other"] != "Speichern" || bundle["backend::lang.form.cancel"]["other"] != "Cancel" {
t.Fatalf("de bundle did not merge the fallback: save=%v cancel=%v", bundle["backend::lang.form.save"], bundle["backend::lang.form.cancel"])
}
for _, tc := range []struct {
name string
overrides fstest.MapFS
want []string
}{
{"missing namespace segment", fstest.MapFS{"lang/pl/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
{"not yaml", fstest.MapFS{"lang/pl/backend/lang.txt": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
{"too deep", fstest.MapFS{"lang/pl/backend/sub/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "malformed override path"}},
{"unknown namespace", fstest.MapFS{"lang/pl/acme.missing/lang.yaml": {Data: []byte("a: b\n")}}, []string{"acme.demo", "acme.missing"}},
{"unconvertible backend key", fstest.MapFS{"lang/en/backend/lang.yaml": {Data: []byte("list:\n results: \"{0} No results|[1,*] :count results\"\n")}}, []string{"backend::lang.list.results", "CLDR"}},
} {
t.Run(tc.name, func(t *testing.T) {
_, err := activateWith(t, overridePlugin{id: "acme.demo", lang: demo, overrides: tc.overrides})
if err == nil {
t.Fatal("activation accepted a bad override")
}
for _, want := range tc.want {
if !strings.Contains(err.Error(), want) {
t.Fatalf("err=%v missing %q", err, want)
}
}
})
}
}
var spaKey = regexp.MustCompile(`backend::lang\.[A-Za-z0-9_.]*[A-Za-z0-9_]`)
// TestPhase10SPAKeysResolve walks the admin SPA sources and resolves every
// backend::lang key literal in pl and en, each without falling back to the
// other locale.
func TestPhase10SPAKeysResolve(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("caller")
}
root := filepath.Join(filepath.Dir(file), "..", "admin", "src")
keys := map[string]bool{}
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !(strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".vue")) {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
for _, key := range spaKey.FindAllString(string(raw), -1) {
keys[key] = true
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(keys) < 10 {
t.Fatalf("found only %d backend::lang keys under %s", len(keys), root)
}
sorted := make([]string, 0, len(keys))
for key := range keys {
sorted = append(sorted, key)
}
sort.Strings(sorted)
cat := NewCatalog()
if err := cat.Load("backend", backendLang()); err != nil {
t.Fatal(err)
}
for _, locale := range []string{"pl", "en"} {
tr := NewTranslator(cat, Options{Locale: locale, Fallback: locale})
for _, key := range sorted {
forms, ok := tr.Forms(locale, key)
if !ok || forms["other"] == "" {
t.Errorf("%s: %s does not resolve", locale, key)
}
}
}
}

View File

@@ -91,6 +91,9 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
if err := cat.Load("lagoon", systemLangFS); err != nil { if err := cat.Load("lagoon", systemLangFS); err != nil {
return err return err
} }
if err := cat.Load("backend", backendLang()); err != nil {
return err
}
for _, p := range plugins { for _, p := range plugins {
hl, ok := any(p).(pact.HasLang) hl, ok := any(p).(pact.HasLang)
if !ok { if !ok {
@@ -100,6 +103,21 @@ func Activate[P interface{ ID() string }](app *backpack.App, plugins []P) error
return err return err
} }
} }
// Overrides run after every namespace is loaded (D-20): a project can
// replace framework or plugin strings and add locales without a rebuild
// of the admin SPA.
for _, p := range plugins {
ho, ok := any(p).(pact.HasLangOverrides)
if !ok {
continue
}
if err := cat.Override(p.ID(), ho.LangOverridesFS()); err != nil {
return err
}
}
if err := cat.checkForms("backend::"); err != nil {
return err
}
opts := Options{Locale: defaultLocale, Fallback: defaultFallback} opts := Options{Locale: defaultLocale, Fallback: defaultFallback}
if app.Config != nil { if app.Config != nil {
if v := strings.TrimSpace(app.Config.String("app.locale")); v != "" { if v := strings.TrimSpace(app.Config.String("app.locale")); v != "" {
@@ -460,3 +478,102 @@ func ucfirst(s string) string {
} }
return string(unicode.ToUpper(r)) + s[size:] return string(unicode.ToUpper(r)) + s[size:]
} }
// Forms returns key's text as CLDR plural forms for the SPA (D-13, D-20): a
// plain text is {"other": text}, a YAML plural map is copied, and a pipe of
// category forms maps by category. Exact and range pipes ({0}, [2,*]) have
// no Intl.PluralRules equivalent and report false, as does a missing key.
// Placeholders are left for the client to interpolate.
func (t *Translator) Forms(locale, key string) (map[string]string, bool) {
if t == nil {
return nil, false
}
e, _, ok := t.find(locale, key)
if !ok {
return nil, false
}
return entryForms(e)
}
// Bundle returns every key starting with prefix as CLDR forms, keys of the
// requested locale over those of its fallback chain. Keys that cannot convert
// are left out.
func (t *Translator) Bundle(locale, prefix string) map[string]map[string]string {
out := map[string]map[string]string{}
if t == nil || t.cat == nil {
return out
}
chain := fallbackChain(locale, t.fallbackLocale())
for i := len(chain) - 1; i >= 0; i-- {
for key, e := range t.cat.messages[chain[i]] {
if !strings.HasPrefix(key, prefix) {
continue
}
if forms, ok := entryForms(e); ok {
out[key] = forms
}
}
}
return out
}
// Resolved returns the first locale of locale's fallback chain that the
// catalog has any key for under prefix: the locale a Bundle is mostly in.
func (t *Translator) Resolved(locale, prefix string) string {
if t == nil || t.cat == nil {
return locale
}
for _, step := range fallbackChain(locale, t.fallbackLocale()) {
for key := range t.cat.messages[step] {
if strings.HasPrefix(key, prefix) {
return step
}
}
}
return t.fallbackLocale()
}
// Has reports whether any locale of the catalog defines key.
func (t *Translator) Has(key string) bool {
if t == nil || t.cat == nil {
return false
}
for _, byKey := range t.cat.messages {
if _, ok := byKey[key]; ok {
return true
}
}
return false
}
func (t *Translator) fallbackLocale() string {
if t == nil || strings.TrimSpace(t.fallback) == "" {
return defaultFallback
}
return t.fallback
}
func entryForms(e entry) (map[string]string, bool) {
switch {
case len(e.pipes) > 0:
out := make(map[string]string, len(e.pipes))
for _, part := range e.pipes {
if part.exact != nil || part.lo != nil || part.cat == "" {
return nil, false
}
out[part.cat] = part.text
}
if _, ok := out["other"]; !ok {
return nil, false
}
return out, true
case e.plurals != nil:
out := make(map[string]string, len(e.plurals))
for cat, text := range e.plurals {
out[cat] = text
}
return out, true
default:
return map[string]string{"other": e.text}, true
}
}