@ecomconsult/consentkit 0.5.14 → 0.5.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,7 +29,7 @@ Vanilla ES2020, zero dependencies, no build step.
29
29
  - **Equal-weight buttons, no pre-ticked boxes** — the consent invariants are
30
30
  fixed by design, see [CONTRIBUTING.md](https://github.com/vermoh/ConsentKit/blob/main/CONTRIBUTING.md)
31
31
 
32
- > **Status: prototype (v0.5.14).** The core, the UI and the demo are verified in
32
+ > **Status: prototype (v0.5.15).** The core, the UI and the demo are verified in
33
33
  > a browser and covered by an automated suite (`npm test`); several distribution
34
34
  > paths are not yet tested against live systems. See
35
35
  > [Project status](#project-status) before shipping this to production.
@@ -193,6 +193,8 @@ Pass any subset to `init()`. Nested objects merge with the defaults.
193
193
  | `texts.policyUrl` | `string` | — | v0.5.0. Cookie policy address. `http(s)` only; anything else is ignored |
194
194
  | `texts.detailsAction` | `"policy" \| "settings" \| "hide" \| "declaration"` | see notes | v0.5.0, `declaration` in v0.5.7. What «Learn more» does. Defaults to `policy` when `policyUrl` is set, `settings` when it is not. `policy` or `declaration` without a usable URL falls back to `settings` rather than rendering a dead link |
195
195
  | `texts.declarationUrl` | `string` | — | v0.5.7. Address of the cookie declaration page, used by `detailsAction: "declaration"`. `http(s)` only. Filled by the hosted service; the client only reads it |
196
+ | `texts.<lang>` | `object` | — | v0.5.15. Per-language dictionary overrides, keyed by a language tag (`ru`, `ro`, `en`, `pt-br`, …). Overridable keys: `bannerTitle`, `bannerText`, `panelTitle`, `panelIntro`, `extraTitle`, `extraText`, and `cat.<necessary\|functional\|analytics\|marketing>.title` / `.desc`. An empty string falls through to the standard text; every other key is ignored — see [Custom texts and links](#custom-texts-and-links) |
197
+ | `texts.links` | `object[]` | `[]` | v0.5.15. Up to 3 links under the banner buttons and at the foot of the settings panel: `{ id, url, label: { ru, ro, en, … } }`. `url` is `http(s)` only; a row with no resolvable label or an unusable address is skipped. Does not affect `detailsAction`, except that «Learn more» is hidden when its `policy` / `declaration` URL repeats one of these links — see [Custom texts and links](#custom-texts-and-links) |
196
198
  | `categories.*.enabled` | `boolean` | `true` | Per category: `functional`, `analytics`, `marketing`. Hides the toggle when `false` |
197
199
  | `consentTtlDays` | `number` | `365` | Lifetime of the stored decision |
198
200
  | `integrations.gcm` | `boolean` | `true` | Google Consent Mode v2 signals |
@@ -363,10 +365,161 @@ Both link forms accept `http(s)` addresses only. A `javascript:` or `data:` URL
363
365
  in a control the visitor is invited to click is an XSS vector, so anything else
364
366
  is refused and the link degrades to `settings`.
365
367
 
368
+ Since 0.5.15 both link forms are also dropped for a render whose `texts.links`
369
+ already show the same address, so the banner never links one page twice — see
370
+ [`texts.links`](#textslinks--your-own-links).
371
+
366
372
  > Before 0.5.0 this control was rendered as `<a href="#">` with no handler at
367
373
  > all: clicking it jumped to the top of the page and nothing else. Any site
368
374
  > running 0.4.x or earlier has a dead «Learn more» link.
369
375
 
376
+ ### Custom texts and links
377
+
378
+ Since 0.5.15 the banner and the panel can carry your own words, per language,
379
+ and up to three of your own links. This exists because a cookie banner in some
380
+ jurisdictions has to name the operator and say where a data subject may
381
+ complain — that is a legal requirement, not decoration, and it does not fit in
382
+ any of the built-in sentences.
383
+
384
+ #### `texts.<lang>` — dictionary overrides
385
+
386
+ A key under `texts` is treated as a language dictionary when — and only when —
387
+ it looks like a language tag, matching `/^[a-z]{2}(-[a-z]{2})?$/`. That is what
388
+ keeps `policyUrl`, `detailsAction`, `declarationUrl` and `links` out of it; no
389
+ scalar setting under `texts` is ever two letters.
390
+
391
+ Overridable keys, and nothing else:
392
+
393
+ | Key | Where it shows |
394
+ |---|---|
395
+ | `bannerTitle` | The banner's heading |
396
+ | `bannerText` | The banner's paragraph |
397
+ | `panelTitle` | The settings panel's heading |
398
+ | `panelIntro` | The line under it |
399
+ | `extraTitle` | Heading of the «Additional information» block (defaults to a translated «Additional information» in all 32 languages) |
400
+ | `extraText` | Body of that block. **Empty in every dictionary** — the block renders only when you supply text |
401
+ | `cat.<name>.title` / `.desc` | One category's name and description, for `necessary`, `functional`, `analytics`, `marketing` |
402
+
403
+ Anything else — `acceptAll`, `more`, `save`, an unknown key — is ignored. The
404
+ button labels are what a visitor recognises across sites, and the plural tables
405
+ are arrays that a string override would break.
406
+
407
+ Values are merged in three layers, in this order, each winning over the one
408
+ before it:
409
+
410
+ ```
411
+ built-in dictionary ← window.__ckLocales ← config.texts[lang]
412
+ ```
413
+
414
+ For the last layer the exact resolved code is tried first, then its two-letter
415
+ base: a banner that resolved to `pt-br` reads `texts['pt-br']`, then
416
+ `texts['pt']`. **A non-empty string wins; an empty string, a missing key or a
417
+ non-string falls through to the layer below.** That is what makes an empty field
418
+ in an editor mean «use the standard text» rather than «show nothing».
419
+
420
+ Every value is treated as **text, never as HTML**, everywhere.
421
+
422
+ #### The markup subset for `extraText`
423
+
424
+ `extraText` is the one field with structure, because an operator block genuinely
425
+ is two or three paragraphs with an address and a link in them. The rules below
426
+ are the whole contract — the hosted service's validator mirrors them exactly,
427
+ so what the cabinet previews is what the banner paints:
428
+
429
+ 1. **Paragraphs.** A blank line (two newlines) starts a new paragraph. Each
430
+ paragraph becomes one `<p>`.
431
+ 2. **Line breaks.** A single newline inside a paragraph becomes a `<br>`.
432
+ 3. **Three passes, in this exact order.** The order is part of the contract,
433
+ not an implementation detail:
434
+ 1. **`[label](url)` links.** Tokenised first, and their pieces are never
435
+ seen by the later passes. This is what stops the bare-URL rule from
436
+ eating the address inside `[label](https://…)`, stops the bare-e-mail
437
+ rule from firing inside a `mailto:` label, and leaves a `**` inside a
438
+ link label literal.
439
+ 2. **`**bold**`**, over the text between those links.
440
+ 3. **Bare addresses**, inside each bold and each plain span: a bare
441
+ `https://` or `http://` URL, and a bare e-mail address, each becoming its
442
+ own label.
443
+ Bold must come *before* the bare addresses, not after: `**mail@example.md**`
444
+ is one bold run that happens to contain an address, and linking the address
445
+ first would split the run and leave the asterisks visible on screen.
446
+ 4. **What the passes produce.** Every link — `[label](https://…)`,
447
+ `[label](mailto:…)`, a bare URL, a bare e-mail — is rendered with
448
+ `target="_blank" rel="noopener"`. `**bold**` becomes `<strong>`, and an
449
+ address inside it is bold *and* clickable. There is no nesting the other
450
+ way: `**` inside a `[label](…)` stays literal, because pass 1 removed the
451
+ whole link before pass 2 ran. An unclosed `**` is two literal asterisks,
452
+ never a bold tail that swallows the paragraph.
453
+ 5. **Any other scheme is not a link.** `[x](javascript:…)`, `data:`, `file:` —
454
+ the *whole literal* `[x](javascript:…)` is rendered as plain text, so a
455
+ mistake is visible to whoever wrote it rather than silently swallowed.
456
+ 6. **No HTML.** `<b>` is four characters of text. Nothing in this path goes
457
+ through `innerHTML`; the block is built with `createElement` and
458
+ `createTextNode` only. The hosted service refuses `<` in these fields
459
+ outright, at validation time.
460
+
461
+ The block is drawn after the categories and their service lists, before the
462
+ panel's buttons, and only when `extraText` resolves to a non-empty string.
463
+
464
+ #### `texts.links` — your own links
465
+
466
+ Up to three, rendered as a row under the banner's buttons (in all three
467
+ layouts) and at the foot of the settings panel:
468
+
469
+ ```js
470
+ texts: {
471
+ links: [
472
+ { id: 'privacy', url: 'https://shop.md/privacy',
473
+ label: { ru: 'Политика конфиденциальности', ro: 'Politica de confidențialitate', en: 'Privacy policy' } },
474
+ { id: 'cookies', url: 'https://shop.md/cookies',
475
+ label: { ru: 'Политика cookie', ro: 'Politica cookie', en: 'Cookie policy' } }
476
+ ]
477
+ }
478
+ ```
479
+
480
+ `url` must be `http(s)`; anything else is skipped. `label` is resolved with the
481
+ same fallback chain as `branding.poweredBy.texts` — exact code, then the
482
+ two-letter base, then `en` — and a row whose label resolves to nothing is
483
+ skipped rather than rendered blank. The cap of three is applied to the rows that
484
+ *survive* those checks, so one malformed entry never costs a good one its place.
485
+
486
+ This does not touch `detailsAction`, with one exception that keeps the banner
487
+ from printing the same address twice: when `detailsAction` resolves to `policy`
488
+ or `declaration` and its URL matches one of the links on screen — compared
489
+ trimmed, with a case-insensitive host and any trailing slash ignored — the
490
+ in-text «Learn more» link is not rendered and the banner behaves as
491
+ `detailsAction: "hide"` for that render, leaving the address to the link row,
492
+ which also carries your own label for it. An explicit `detailsAction:
493
+ "settings"` is unaffected: it opens the panel and has no address to duplicate.
494
+
495
+ #### A worked example
496
+
497
+ ```json
498
+ {
499
+ "language": "auto",
500
+ "texts": {
501
+ "policyUrl": "https://shop.md/privacy",
502
+ "ru": {
503
+ "bannerTitle": "Cookie на сайте INTERSTEPCOM",
504
+ "extraText": "Оператор: «FIRM» SRL, IDNO 1234567890123, мун. Кишинёв, ул. Примерная 1.\n\nПо вопросам обработки персональных данных пишите на **office@firm.md** — отвечаем не позднее одного месяца (ст. 12 ч. (3) Закона № 195/2024).\n\nВы вправе подать жалобу в [Национальный центр по защите персональных данных](https://datepersonale.md)."
505
+ },
506
+ "links": [
507
+ { "id": "privacy", "url": "https://shop.md/privacy",
508
+ "label": { "ru": "Политика конфиденциальности", "ro": "Politica de confidențialitate" } },
509
+ { "id": "cookies", "url": "https://shop.md/cookies",
510
+ "label": { "ru": "Политика cookie", "ro": "Politica cookie" } }
511
+ ]
512
+ }
513
+ }
514
+ ```
515
+
516
+ A Russian visitor sees the custom banner title, and a panel whose «Дополнительно»
517
+ card carries three paragraphs: the operator's details, a bold auto-linked
518
+ address with the statutory answering period, and a link to the supervisory
519
+ authority. A Romanian visitor sees the standard Romanian banner title (nothing
520
+ was overridden for `ro`), no «Informații suplimentare» card (no `ro.extraText`),
521
+ and both links under the buttons in Romanian.
522
+
370
523
  ### Reopening the settings
371
524
 
372
525
  `ConsentKit.openSettings()` opens the preferences panel from anywhere on the
@@ -819,7 +972,7 @@ external requests. Rebuild them with `tools/build-inline.mjs` (see
819
972
  [`tools/README.md`](https://github.com/vermoh/ConsentKit/blob/main/tools/README.md)); each block's header records the exact
820
973
  command that produced it.
821
974
 
822
- ConsentKit 0.5.14, rebuilt 2026-09-07, uncompressed — gzip on the server cuts
975
+ ConsentKit 0.5.15, rebuilt 2026-09-07, uncompressed — gzip on the server cuts
823
976
  this roughly threefold. Every block includes the branding extension and the
824
977
  attribution line; `--no-branding` drops both the code and the config and takes
825
978
  **~26 KB** back off:
@@ -1023,6 +1176,30 @@ Client versions. The WordPress plugin tracks the same numbers and keeps its own
1023
1176
  notes in
1024
1177
  [`plugins/wordpress/consentkit/readme.txt`](https://github.com/vermoh/ConsentKit/blob/main/plugins/wordpress/consentkit/readme.txt).
1025
1178
 
1179
+ ### 0.5.15
1180
+
1181
+ - **Your own texts, per language.** `texts.<lang>` overrides the banner title
1182
+ and copy, the panel title and intro, and any category's title or description,
1183
+ for one language at a time. An empty field means «take the standard text», so
1184
+ a partly filled form is not a partly blank banner — see
1185
+ [Custom texts and links](#custom-texts-and-links).
1186
+ - **An «Additional information» block in the settings panel.** `texts.<lang>.extraText`
1187
+ renders as a card under the categories: who the operator is, where to write,
1188
+ how long an answer takes, where to complain. It accepts a small, precisely
1189
+ defined markup subset — paragraphs, `**bold**`, links and auto-linked e-mail
1190
+ addresses — and no HTML at all: the block is built with `createElement` and
1191
+ `createTextNode`, never `innerHTML`.
1192
+ - **Up to three of your own links under the banner buttons** and at the foot of
1193
+ the settings panel, via `texts.links`. Each carries a per-language label, is
1194
+ `http(s)`-only, and opens with `target="_blank" rel="noopener"`. «Learn more»
1195
+ is unaffected: a site that adds links keeps whatever `detailsAction` already
1196
+ did — except that the in-text link is dropped when it would repeat an address
1197
+ already on screen in the link row, so the same page is never linked twice.
1198
+ - **The debug panel reports which language got overrides**, so «I filled in the
1199
+ text and the banner still shows the standard one» has an answer on screen —
1200
+ usually that the override is filed under a language code the banner did not
1201
+ resolve to.
1202
+
1026
1203
  ### 0.5.14
1027
1204
 
1028
1205
  - **The loader tolerates a second snippet with a dead site id.** A page that
@@ -1160,7 +1337,7 @@ notes in
1160
1337
 
1161
1338
  ## Project status
1162
1339
 
1163
- **This is a prototype (v0.5.14), not a released product.** It is honest about
1340
+ **This is a prototype (v0.5.15), not a released product.** It is honest about
1164
1341
  what has been verified and what has not.
1165
1342
 
1166
1343
  ### Verified
package/npm/index.d.ts CHANGED
@@ -168,6 +168,40 @@ export interface CkTextsConfig {
168
168
  * service; the client only reads it.
169
169
  */
170
170
  declarationUrl?: string;
171
+ /**
172
+ * v0.5.15. Up to three links rendered under the banner buttons and at the
173
+ * bottom of the preferences panel. `url` is `http(s)` only; `label` is
174
+ * resolved per language the way `branding.poweredBy.texts` is. A link whose
175
+ * address equals the «Learn more» target hides the in-text link.
176
+ */
177
+ links?: CkTextLink[];
178
+ /**
179
+ * v0.5.15. Per-language overrides of the built-in dictionary, keyed by a
180
+ * language code (`ru`, `ro`, `en`, `pt-br`, …). An empty string means «use
181
+ * the dictionary». `extraText` accepts the rich-text subset documented in
182
+ * the README («Custom texts and links»); it is never interpreted as HTML.
183
+ */
184
+ [lang: string]: CkTextOverrides | CkTextLink[] | string | undefined;
185
+ }
186
+
187
+ /** v0.5.15. One link under the banner buttons. */
188
+ export interface CkTextLink {
189
+ id: string;
190
+ url: string;
191
+ label: Record<string, string>;
192
+ }
193
+
194
+ /** v0.5.15. The dictionary keys a site may override for one language. */
195
+ export interface CkTextOverrides {
196
+ bannerTitle?: string;
197
+ bannerText?: string;
198
+ panelTitle?: string;
199
+ panelIntro?: string;
200
+ /** Heading of the «Additional information» block in the preferences panel. */
201
+ extraTitle?: string;
202
+ /** Body of that block; rendered only when non-empty. Rich-text subset. */
203
+ extraText?: string;
204
+ cat?: Partial<Record<'necessary' | 'functional' | 'analytics' | 'marketing', { title?: string; desc?: string }>>;
171
205
  }
172
206
 
173
207
  /** Whether a category is offered in the preferences panel at all. */
@@ -421,7 +455,7 @@ export interface CkContrastApi {
421
455
  resolveFont(theme: CkThemeConfig): string;
422
456
  /** Takes the whole config, not just `texts`. */
423
457
  resolveDetails(config: CkConfig):
424
- { kind: 'policy' | 'settings' | 'hide'; href: string | null };
458
+ { kind: 'policy' | 'settings' | 'hide' | 'declaration'; href: string | null };
425
459
  /** Takes the whole config. Returns the generated stylesheet and both palettes. */
426
460
  buildThemeCss(config: CkConfig): {
427
461
  css: string;
@@ -28,7 +28,7 @@ export function undecidedState() {
28
28
  */
29
29
  export function createStub() {
30
30
  const stub = {
31
- version: '0.5.14',
31
+ version: '0.5.15',
32
32
  config: {},
33
33
  init: function () { return undecidedState(); },
34
34
  allowed: function (cat) { return cat === 'necessary'; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ecomconsult/consentkit",
3
- "version": "0.5.14",
3
+ "version": "0.5.15",
4
4
  "description": "GDPR cookie consent core with blocking engine, Shadow DOM UI and Google Consent Mode v2. Zero dependencies, no build step.",
5
5
  "repository": {
6
6
  "type": "git",
package/src/ck-core.js CHANGED
@@ -2001,7 +2001,7 @@
2001
2001
  // Public API
2002
2002
  // ---------------------------------------------------------------------------
2003
2003
  var ConsentKit = {
2004
- version: '0.5.14',
2004
+ version: '0.5.15',
2005
2005
  config: config,
2006
2006
 
2007
2007
  init: function (userConfig) {
package/src/ck-debug.js CHANGED
@@ -312,6 +312,12 @@
312
312
  themeCard: 'карточка',
313
313
  themeBtn: 'кнопки',
314
314
  themeLink: 'Ссылки',
315
+ // SPEC V1.16 §1.4 — какой язык получил свои тексты из конфига. Отвечает
316
+ // на вопрос «почему на баннере не тот текст, который я вписал»: чаще
317
+ // всего потому, что переопределение записано под другим кодом языка.
318
+ textsOverride: 'Тексты',
319
+ textsFor: 'переопределения для ',
320
+ textsNone: 'нет',
315
321
  btnAccept: 'Принять всё',
316
322
  btnReject: 'Отклонить всё',
317
323
  btnSettings: 'Настроить',
@@ -387,6 +393,9 @@
387
393
  themeCard: 'card',
388
394
  themeBtn: 'buttons',
389
395
  themeLink: 'Links',
396
+ textsOverride: 'Texts',
397
+ textsFor: 'overrides for ',
398
+ textsNone: 'none',
390
399
  btnAccept: 'Accept all',
391
400
  btnReject: 'Reject all',
392
401
  btnSettings: 'Customize',
@@ -463,6 +472,9 @@
463
472
  themeCard: 'card',
464
473
  themeBtn: 'butoane',
465
474
  themeLink: 'Linkuri',
475
+ textsOverride: 'Texte',
476
+ textsFor: 'suprascrieri pentru ',
477
+ textsNone: 'niciuna',
466
478
  btnAccept: 'Acceptă tot',
467
479
  btnReject: 'Respinge tot',
468
480
  btnSettings: 'Personalizează',
@@ -853,6 +865,45 @@
853
865
  : 'inherit (' + T.themeFontInherit + tries + ')';
854
866
  }
855
867
 
868
+ /* SPEC V1.16 §1.4 — «Тексты: переопределения для ru» / «нет».
869
+
870
+ The question this answers is «I filled the banner text in the cabinet and
871
+ the banner still shows the standard one». Nine times in ten the answer is
872
+ that the override is filed under a language code the banner did not resolve
873
+ to — so the row reports the RESOLVED code, not the configured one, and says
874
+ which of the two lookups (exact, then two-letter base) actually hit.
875
+
876
+ The resolution is asked of ck-ui through _contrast rather than repeated
877
+ here: «один код — одни числа», the same rule the Appearance section above
878
+ already follows. With ck-ui absent (a core-only page) there is no banner
879
+ language to report and the row reads «нет». */
880
+ function textsRow(T) {
881
+ var cfg = (CK && CK.config) || {};
882
+ var texts = (cfg.texts && typeof cfg.texts === 'object' && !Array.isArray(cfg.texts))
883
+ ? cfg.texts : null;
884
+ if (!texts) return T.textsNone;
885
+ var lang = '';
886
+ try {
887
+ var C = CK && CK._contrast;
888
+ if (C && typeof C.resolveLang === 'function' && typeof C.localeTable === 'function') {
889
+ lang = String(C.resolveLang(cfg.language, C.localeTable()) || '').toLowerCase();
890
+ }
891
+ } catch (e) { lang = ''; }
892
+ if (!lang) return T.textsNone;
893
+
894
+ function has(k) {
895
+ return !!(k && /^[a-z]{2}(-[a-z]{2})?$/.test(k) &&
896
+ Object.prototype.hasOwnProperty.call(texts, k) &&
897
+ texts[k] && typeof texts[k] === 'object' && !Array.isArray(texts[k]));
898
+ }
899
+ // The exact code first, then its two-letter base — the same order
900
+ // buildStrings() merges them in, so the row names the key that actually won.
901
+ if (has(lang)) return T.textsFor + lang;
902
+ var base = lang.slice(0, 2);
903
+ if (base !== lang && has(base)) return T.textsFor + base;
904
+ return T.textsNone;
905
+ }
906
+
856
907
  // Resolved lazily, not at parse time: this file runs before ConsentKit.init()
857
908
  // has merged the site's config, so asking for the language now would always
858
909
  // read the built-in default. Re-resolved on every render so a page that
@@ -1076,7 +1127,9 @@
1076
1127
  [T.themeFont, fontRow(built, T)],
1077
1128
  [T.themeRadius, T.themeCard + ' ' + built.radius.card + 'px · ' +
1078
1129
  T.themeBtn + ' ' + built.radius.button + 'px'],
1079
- [T.themeLink, lkTxt]
1130
+ [T.themeLink, lkTxt],
1131
+ // SPEC V1.16 §1.4 — «Тексты: переопределения для ru» / «нет».
1132
+ [T.textsOverride, textsRow(T)]
1080
1133
  ]));
1081
1134
 
1082
1135
  var labels = { accept: T.btnAccept, reject: T.btnReject, settings: T.btnSettings };
package/src/ck-locales.js CHANGED
@@ -8,7 +8,11 @@
8
8
  + uk tr no nb is sr ca sq mk
9
9
 
10
10
  Key set mirrors the builtin en dictionary of ck-ui.js exactly:
11
- 19 flat strings + cat.{necessary,functional,analytics,marketing}.{title,desc}.
11
+ 19 flat strings + cat.{necessary,functional,analytics,marketing}.{title,desc},
12
+ plus `extraTitle` (SPEC V1.16 §1.2 — the heading of the «Дополнительно» card
13
+ in the settings panel). Its BODY, `extraText`, is deliberately not here: the
14
+ block renders only from what the site itself supplies, so a translated body
15
+ would put an empty operator card on every site that never configured one.
12
16
 
13
17
  NOTE: these translations are drafts produced for the prototype. They MUST be reviewed
14
18
  by native speakers before production use — legal wording (refusal, "always on") is the
@@ -40,6 +44,7 @@
40
44
  colPurpose: 'Цел',
41
45
  colExpiry: 'Изтича',
42
46
  floating: 'Настройки на бисквитките',
47
+ extraTitle: 'Допълнителна информация',
43
48
  cat: {
44
49
  necessary: { title: 'Необходими', desc: 'Нужни са, за да работи сайтът — вход, сигурност, запомняне на съгласието ви. Не могат да се изключат.' },
45
50
  functional: { title: 'Функционални', desc: 'Запомнят предпочитанията ви, например език или чат, за да не ги задавате отново.' },
@@ -68,6 +73,7 @@
68
73
  colPurpose: 'Účel',
69
74
  colExpiry: 'Platnost',
70
75
  floating: 'Nastavení cookies',
76
+ extraTitle: 'Doplňující informace',
71
77
  cat: {
72
78
  necessary: { title: 'Nezbytné', desc: 'Bez nich web nefunguje — přihlášení, bezpečnost, uložení vašeho souhlasu. Nelze je vypnout.' },
73
79
  functional: { title: 'Funkční', desc: 'Pamatují si vaše předvolby, třeba jazyk nebo chat, abyste je nenastavovali znovu.' },
@@ -96,6 +102,7 @@
96
102
  colPurpose: 'Formål',
97
103
  colExpiry: 'Udløber',
98
104
  floating: 'Cookieindstillinger',
105
+ extraTitle: 'Yderligere oplysninger',
99
106
  cat: {
100
107
  necessary: { title: 'Nødvendige', desc: 'Nødvendige for at siden virker — login, sikkerhed, hukommelse om dit samtykke. De kan ikke slås fra.' },
101
108
  functional: { title: 'Funktionelle', desc: 'Husker dine indstillinger, fx sprog eller chat, så du ikke skal vælge dem igen.' },
@@ -124,6 +131,7 @@
124
131
  colPurpose: 'Zweck',
125
132
  colExpiry: 'Laufzeit',
126
133
  floating: 'Cookie-Einstellungen',
134
+ extraTitle: 'Weitere Informationen',
127
135
  cat: {
128
136
  necessary: { title: 'Notwendig', desc: 'Nötig, damit die Website funktioniert — Anmeldung, Sicherheit, Speichern Ihrer Einwilligung. Sie lassen sich nicht abschalten.' },
129
137
  functional: { title: 'Funktional', desc: 'Merken sich Ihre Einstellungen, etwa Sprache oder Chat, damit Sie sie nicht erneut wählen müssen.' },
@@ -152,6 +160,7 @@
152
160
  colPurpose: 'Σκοπός',
153
161
  colExpiry: 'Λήξη',
154
162
  floating: 'Ρυθμίσεις cookies',
163
+ extraTitle: 'Πρόσθετες πληροφορίες',
155
164
  cat: {
156
165
  necessary: { title: 'Απαραίτητα', desc: 'Χρειάζονται για να λειτουργεί ο ιστότοπος — σύνδεση, ασφάλεια, διατήρηση της επιλογής σας. Δεν απενεργοποιούνται.' },
157
166
  functional: { title: 'Λειτουργικά', desc: 'Θυμούνται τις προτιμήσεις σας, όπως γλώσσα ή συνομιλία, ώστε να μην τις ορίζετε ξανά.' },
@@ -180,6 +189,7 @@
180
189
  colPurpose: 'Finalidad',
181
190
  colExpiry: 'Caduca',
182
191
  floating: 'Configuración de cookies',
192
+ extraTitle: 'Información adicional',
183
193
  cat: {
184
194
  necessary: { title: 'Necesarias', desc: 'Imprescindibles para que el sitio funcione: inicio de sesión, seguridad y memoria de su consentimiento. No se pueden desactivar.' },
185
195
  functional: { title: 'Funcionales', desc: 'Recuerdan sus preferencias, como el idioma o el chat, para que no tenga que configurarlas de nuevo.' },
@@ -208,6 +218,7 @@
208
218
  colPurpose: 'Eesmärk',
209
219
  colExpiry: 'Kehtivus',
210
220
  floating: 'Küpsiste seaded',
221
+ extraTitle: 'Lisateave',
211
222
  cat: {
212
223
  necessary: { title: 'Vajalikud', desc: 'Vajalikud saidi tööks — sisselogimine, turvalisus, teie nõusoleku meelespidamine. Neid ei saa välja lülitada.' },
213
224
  functional: { title: 'Funktsionaalsed', desc: 'Jätavad meelde teie eelistused, näiteks keele või vestluse, et te ei peaks neid uuesti valima.' },
@@ -236,6 +247,7 @@
236
247
  colPurpose: 'Tarkoitus',
237
248
  colExpiry: 'Voimassaolo',
238
249
  floating: 'Evästeasetukset',
250
+ extraTitle: 'Lisätiedot',
239
251
  cat: {
240
252
  necessary: { title: 'Välttämättömät', desc: 'Tarvitaan sivuston toimintaan: kirjautuminen, turvallisuus ja suostumuksenne muistaminen. Näitä ei voi poistaa käytöstä.' },
241
253
  functional: { title: 'Toiminnalliset', desc: 'Muistavat asetuksenne, kuten kielen tai chatin, jottei niitä tarvitse valita uudelleen.' },
@@ -264,6 +276,7 @@
264
276
  colPurpose: 'Finalité',
265
277
  colExpiry: 'Expiration',
266
278
  floating: 'Paramètres des cookies',
279
+ extraTitle: 'Informations complémentaires',
267
280
  cat: {
268
281
  necessary: { title: 'Nécessaires', desc: 'Indispensables au fonctionnement du site : connexion, sécurité, mémorisation de votre choix. Ils ne peuvent pas être désactivés.' },
269
282
  functional: { title: 'Fonctionnels', desc: 'Retiennent vos préférences, par exemple la langue ou le chat, pour ne pas avoir à les régler de nouveau.' },
@@ -292,6 +305,7 @@
292
305
  colPurpose: 'Cuspóir',
293
306
  colExpiry: 'Éagann',
294
307
  floating: 'Socruithe fianán',
308
+ extraTitle: 'Eolas breise',
295
309
  cat: {
296
310
  necessary: { title: 'Riachtanach', desc: 'Ag teastáil chun an suíomh a oibriú — logáil isteach, slándáil, do thoiliú a choinneáil. Ní féidir iad a mhúchadh.' },
297
311
  functional: { title: 'Feidhmiúil', desc: 'Coinníonn siad do roghanna, mar shampla an teanga nó an comhrá, ionas nach gá duit iad a shocrú arís.' },
@@ -320,6 +334,7 @@
320
334
  colPurpose: 'Svrha',
321
335
  colExpiry: 'Istječe',
322
336
  floating: 'Postavke kolačića',
337
+ extraTitle: 'Dodatne informacije',
323
338
  cat: {
324
339
  necessary: { title: 'Nužni', desc: 'Potrebni su za rad stranice — prijava, sigurnost, pamćenje vaše privole. Ne mogu se isključiti.' },
325
340
  functional: { title: 'Funkcionalni', desc: 'Pamte vaše postavke, primjerice jezik ili chat, da ih ne morate ponovno birati.' },
@@ -348,6 +363,7 @@
348
363
  colPurpose: 'Cél',
349
364
  colExpiry: 'Lejárat',
350
365
  floating: 'Sütibeállítások',
366
+ extraTitle: 'További információk',
351
367
  cat: {
352
368
  necessary: { title: 'Szükséges', desc: 'Az oldal működéséhez kellenek: belépés, biztonság, a hozzájárulása megjegyzése. Nem kapcsolhatók ki.' },
353
369
  functional: { title: 'Funkcionális', desc: 'Megjegyzik a beállításait, például a nyelvet vagy a chatet, hogy ne kelljen újra megadnia őket.' },
@@ -376,6 +392,7 @@
376
392
  colPurpose: 'Finalità',
377
393
  colExpiry: 'Scadenza',
378
394
  floating: 'Impostazioni dei cookie',
395
+ extraTitle: 'Informazioni aggiuntive',
379
396
  cat: {
380
397
  necessary: { title: 'Necessari', desc: 'Servono al funzionamento del sito: accesso, sicurezza, memoria del suo consenso. Non possono essere disattivati.' },
381
398
  functional: { title: 'Funzionali', desc: 'Ricordano le sue preferenze, come la lingua o la chat, così non deve impostarle di nuovo.' },
@@ -404,6 +421,7 @@
404
421
  colPurpose: 'Paskirtis',
405
422
  colExpiry: 'Galiojimas',
406
423
  floating: 'Slapukų nustatymai',
424
+ extraTitle: 'Papildoma informacija',
407
425
  cat: {
408
426
  necessary: { title: 'Būtini', desc: 'Reikalingi, kad svetainė veiktų: prisijungimas, saugumas, jūsų sutikimo įsiminimas. Jų išjungti negalima.' },
409
427
  functional: { title: 'Funkciniai', desc: 'Įsimena jūsų nustatymus, pavyzdžiui, kalbą ar pokalbius, kad jų nereikėtų nurodyti iš naujo.' },
@@ -432,6 +450,7 @@
432
450
  colPurpose: 'Nolūks',
433
451
  colExpiry: 'Derīgums',
434
452
  floating: 'Sīkdatņu iestatījumi',
453
+ extraTitle: 'Papildu informācija',
435
454
  cat: {
436
455
  necessary: { title: 'Nepieciešamās', desc: 'Vajadzīgas vietnes darbībai — pieteikšanās, drošība, jūsu piekrišanas saglabāšana. Tās nevar izslēgt.' },
437
456
  functional: { title: 'Funkcionālās', desc: 'Atceras jūsu iestatījumus, piemēram, valodu vai tērzēšanu, lai tie nebūtu jānorāda atkārtoti.' },
@@ -460,6 +479,7 @@
460
479
  colPurpose: 'Skop',
461
480
  colExpiry: 'Skadenza',
462
481
  floating: 'Issettjar tal-cookies',
482
+ extraTitle: 'Informazzjoni addizzjonali',
463
483
  cat: {
464
484
  necessary: { title: 'Meħtieġa', desc: 'Meħtieġa biex is-sit jaħdem — login, sigurtà, tifkira tal-kunsens tiegħek. Ma jistgħux jintfew.' },
465
485
  functional: { title: 'Funzjonali', desc: 'Jiftakru l-preferenzi tiegħek, bħal-lingwa jew iċ-chat, biex ma jkollokx terġa’ tissettjahom.' },
@@ -488,6 +508,7 @@
488
508
  colPurpose: 'Doel',
489
509
  colExpiry: 'Vervalt',
490
510
  floating: 'Cookie-instellingen',
511
+ extraTitle: 'Aanvullende informatie',
491
512
  cat: {
492
513
  necessary: { title: 'Noodzakelijk', desc: 'Nodig om de site te laten werken: inloggen, beveiliging en het onthouden van uw keuze. Ze kunnen niet uit.' },
493
514
  functional: { title: 'Functioneel', desc: 'Onthouden uw voorkeuren, zoals taal of chat, zodat u ze niet opnieuw hoeft in te stellen.' },
@@ -516,6 +537,7 @@
516
537
  colPurpose: 'Cel',
517
538
  colExpiry: 'Wygasa',
518
539
  floating: 'Ustawienia plików cookie',
540
+ extraTitle: 'Informacje dodatkowe',
519
541
  cat: {
520
542
  necessary: { title: 'Niezbędne', desc: 'Potrzebne, by strona działała: logowanie, bezpieczeństwo, zapamiętanie zgody. Nie można ich wyłączyć.' },
521
543
  functional: { title: 'Funkcjonalne', desc: 'Zapamiętują ustawienia, na przykład język lub czat, żeby nie trzeba było wybierać ich ponownie.' },
@@ -544,6 +566,7 @@
544
566
  colPurpose: 'Finalidade',
545
567
  colExpiry: 'Validade',
546
568
  floating: 'Definições de cookies',
569
+ extraTitle: 'Informações adicionais',
547
570
  cat: {
548
571
  necessary: { title: 'Necessários', desc: 'Precisos para o site funcionar: início de sessão, segurança e memória do seu consentimento. Não podem ser desativados.' },
549
572
  functional: { title: 'Funcionais', desc: 'Guardam as suas preferências, como o idioma ou o chat, para não ter de as definir outra vez.' },
@@ -572,6 +595,7 @@
572
595
  colPurpose: 'Pentru ce',
573
596
  colExpiry: 'Expiră',
574
597
  floating: 'Setări cookie-uri',
598
+ extraTitle: 'Informații suplimentare',
575
599
  // SPEC V1.10 §2 — заглушка встраивания. Only ro carries these here: en and
576
600
  // ru are builtin in ck-ui.js, and every other locale falls back to en.
577
601
  phText: 'Aici este conținut de la {host}. Se va încărca după acordul pentru «{cat}».',
@@ -616,6 +640,7 @@
616
640
  colPurpose: 'Účel',
617
641
  colExpiry: 'Platnosť',
618
642
  floating: 'Nastavenia cookies',
643
+ extraTitle: 'Doplňujúce informácie',
619
644
  cat: {
620
645
  necessary: { title: 'Nevyhnutné', desc: 'Bez nich stránka nefunguje — prihlásenie, bezpečnosť, zapamätanie vášho súhlasu. Nedajú sa vypnúť.' },
621
646
  functional: { title: 'Funkčné', desc: 'Pamätajú si vaše nastavenia, napríklad jazyk alebo chat, aby ste ich nezadávali znova.' },
@@ -644,6 +669,7 @@
644
669
  colPurpose: 'Namen',
645
670
  colExpiry: 'Poteče',
646
671
  floating: 'Nastavitve piškotkov',
672
+ extraTitle: 'Dodatne informacije',
647
673
  cat: {
648
674
  necessary: { title: 'Nujni', desc: 'Potrebni za delovanje spletnega mesta — prijava, varnost, pomnjenje vašega soglasja. Ni jih mogoče izklopiti.' },
649
675
  functional: { title: 'Funkcionalni', desc: 'Zapomnijo si vaše nastavitve, na primer jezik ali klepet, da jih ni treba določati znova.' },
@@ -672,6 +698,7 @@
672
698
  colPurpose: 'Syfte',
673
699
  colExpiry: 'Upphör',
674
700
  floating: 'Cookieinställningar',
701
+ extraTitle: 'Ytterligare information',
675
702
  cat: {
676
703
  necessary: { title: 'Nödvändiga', desc: 'Krävs för att webbplatsen ska fungera — inloggning, säkerhet, minne av ditt samtycke. De kan inte stängas av.' },
677
704
  functional: { title: 'Funktionella', desc: 'Kommer ihåg dina inställningar, till exempel språk eller chatt, så att du slipper välja igen.' },
@@ -700,6 +727,7 @@
700
727
  colPurpose: 'Мета',
701
728
  colExpiry: 'Термін',
702
729
  floating: 'Налаштування cookie',
730
+ extraTitle: 'Додаткова інформація',
703
731
  cat: {
704
732
  necessary: { title: 'Необхідні', desc: 'Без них сайт не працює: вхід, безпека, пам’ять про ваш вибір. Вимкнути неможливо.' },
705
733
  functional: { title: 'Функціональні', desc: 'Запам’ятовують ваші налаштування — наприклад мову або чат, — щоб ви не задавали їх знову.' },
@@ -728,6 +756,7 @@
728
756
  colPurpose: 'Amaç',
729
757
  colExpiry: 'Süre',
730
758
  floating: 'Çerez ayarları',
759
+ extraTitle: 'Ek bilgiler',
731
760
  cat: {
732
761
  necessary: { title: 'Zorunlu', desc: 'Sitenin çalışması için gerekli: oturum açma, güvenlik, onayınızın hatırlanması. Kapatılamazlar.' },
733
762
  functional: { title: 'İşlevsel', desc: 'Dil veya sohbet gibi tercihlerinizi hatırlar, böylece yeniden ayarlamanız gerekmez.' },
@@ -756,6 +785,7 @@
756
785
  colPurpose: 'Formål',
757
786
  colExpiry: 'Utløper',
758
787
  floating: 'Innstillinger for informasjonskapsler',
788
+ extraTitle: 'Tilleggsinformasjon',
759
789
  cat: {
760
790
  necessary: { title: 'Nødvendige', desc: 'Kreves for at nettstedet skal virke — innlogging, sikkerhet, minne om samtykket ditt. De kan ikke slås av.' },
761
791
  functional: { title: 'Funksjonelle', desc: 'Husker innstillingene dine, som språk eller chat, så du slipper å velge på nytt.' },
@@ -788,6 +818,7 @@
788
818
  colPurpose: 'Tilgangur',
789
819
  colExpiry: 'Gildistími',
790
820
  floating: 'Stillingar vefkaka',
821
+ extraTitle: 'Viðbótarupplýsingar',
791
822
  cat: {
792
823
  necessary: { title: 'Nauðsynlegar', desc: 'Nauðsynlegar til að vefurinn virki — innskráning, öryggi, að muna samþykki þitt. Ekki er hægt að slökkva á þeim.' },
793
824
  functional: { title: 'Virknivefkökur', desc: 'Muna stillingar þínar, til dæmis tungumál eða spjall, svo þú þurfir ekki að velja aftur.' },
@@ -817,6 +848,7 @@
817
848
  colPurpose: 'Сврха',
818
849
  colExpiry: 'Истиче',
819
850
  floating: 'Подешавања колачића',
851
+ extraTitle: 'Додатне информације',
820
852
  cat: {
821
853
  necessary: { title: 'Неопходни', desc: 'Потребни су да би сајт радио — пријава, безбедност, памћење ваше сагласности. Не могу се искључити.' },
822
854
  functional: { title: 'Функционални', desc: 'Памте ваша подешавања, на пример језик или ћаскање, да их не бисте бирали поново.' },
@@ -845,6 +877,7 @@
845
877
  colPurpose: 'Finalitat',
846
878
  colExpiry: 'Caduca',
847
879
  floating: 'Configuració de galetes',
880
+ extraTitle: 'Informació addicional',
848
881
  cat: {
849
882
  necessary: { title: 'Necessàries', desc: 'Calen perquè el lloc funcioni: inici de sessió, seguretat i memòria del vostre consentiment. No es poden desactivar.' },
850
883
  functional: { title: 'Funcionals', desc: 'Recorden les vostres preferències, com l’idioma o el xat, perquè no les hàgiu de tornar a configurar.' },
@@ -873,6 +906,7 @@
873
906
  colPurpose: 'Qëllimi',
874
907
  colExpiry: 'Skadon',
875
908
  floating: 'Cilësimet e cookie-ve',
909
+ extraTitle: 'Informacione shtesë',
876
910
  cat: {
877
911
  necessary: { title: 'Të nevojshme', desc: 'Nevojiten që faqja të funksionojë — hyrja, siguria, ruajtja e pëlqimit tuaj. Nuk mund të fiken.' },
878
912
  functional: { title: 'Funksionale', desc: 'Mbajnë mend preferencat tuaja, si gjuha ose biseda, që të mos i vendosni sërish.' },
@@ -901,6 +935,7 @@
901
935
  colPurpose: 'Цел',
902
936
  colExpiry: 'Истекува',
903
937
  floating: 'Поставки за колачиња',
938
+ extraTitle: 'Дополнителни информации',
904
939
  cat: {
905
940
  necessary: { title: 'Неопходни', desc: 'Потребни се за страницата да работи — најава, безбедност, помнење на вашата согласност. Не можат да се исклучат.' },
906
941
  functional: { title: 'Функционални', desc: 'Ги паметат вашите поставки, на пример јазикот или разговорот, за да не ги задавате повторно.' },
package/src/ck-ui.js CHANGED
@@ -33,6 +33,15 @@
33
33
  bannerLabel: 'Cookie consent',
34
34
  panelTitle: 'Cookie settings',
35
35
  panelIntro: 'Choose what to allow. By default only the necessary ones are on.',
36
+ /* SPEC V1.16 §1.2 — the «Additional information» card at the foot of the
37
+ panel: who the operator is, where to write, how long an answer takes,
38
+ where to complain. The HEADING has a real translation in every locale;
39
+ the BODY is deliberately empty in every dictionary, because the block
40
+ renders only when the site actually supplies text. A default sentence
41
+ here would put an empty operator card on every site that never
42
+ configured one. */
43
+ extraTitle: 'Additional information',
44
+ extraText: '',
36
45
  save: 'Save choices',
37
46
  close: 'Close',
38
47
  alwaysOn: 'always on',
@@ -101,6 +110,10 @@
101
110
  bannerLabel: 'Согласие на cookie',
102
111
  panelTitle: 'Настройки cookie',
103
112
  panelIntro: 'Выберите, что разрешить. По умолчанию включено только необходимое.',
113
+ // SPEC V1.16 §1.2. Заголовок переведён везде, тело пустое везде — см.
114
+ // комментарий у en выше.
115
+ extraTitle: 'Дополнительно',
116
+ extraText: '',
104
117
  save: 'Сохранить выбор',
105
118
  close: 'Закрыть',
106
119
  alwaysOn: 'всегда активны',
@@ -169,7 +182,16 @@
169
182
  // group's own one. Both are plain strings, so they belong here: a key left
170
183
  // out of this list is `undefined` for all 32 external locales and renders
171
184
  // the literal word "undefined" on the card.
172
- 'svcDetails', 'svcListLabel'
185
+ 'svcDetails', 'svcListLabel',
186
+ /* SPEC V1.16 §1.2 — the «Дополнительно» card. Both belong here for the
187
+ usual reason (a DICT key missing from this list is `undefined` for all 32
188
+ external locales), but they behave differently on purpose:
189
+
190
+ extraTitle — translated everywhere, so every locale has a heading.
191
+ extraText — '' in every dictionary. The merge line below keeps '' when
192
+ the locale supplies nothing, and the panel treats '' as
193
+ «no block». Only config.texts.<lang>.extraText fills it. */
194
+ 'extraTitle', 'extraText'
173
195
  ];
174
196
 
175
197
  // Plural-form keys, filled separately from STR_KEYS (see above).
@@ -260,13 +282,74 @@
260
282
  return list[idx].replace('{n}', String(n));
261
283
  }
262
284
 
263
- // Deep two-level fill from en: a partial locale must never yield undefined,
264
- // which would render the literal string "undefined".
265
- function buildStrings(lang, table) {
285
+ /* SPEC V1.16 §1.1 per-language dictionary overrides from the CONFIG.
286
+
287
+ `config.texts` is a mixed bag: it has carried `policyUrl`, `detailsAction`,
288
+ `declarationUrl` and `cabinetUrl` since 0.5.0, and 0.5.15 adds `links`. So a
289
+ key is a language dictionary only when it LOOKS like a language tag — that
290
+ regex is the whole separation, and it is why no future scalar setting under
291
+ `texts` can ever be mistaken for one (none of them is two letters).
292
+
293
+ Own properties only, and the value must be a plain object: `texts` is
294
+ author-supplied JSON that may have been merged over a prototype, and
295
+ reading an inherited key would hand the merge below a function. */
296
+ var LANG_KEY_RE = /^[a-z]{2}(-[a-z]{2})?$/;
297
+
298
+ function configTexts(cfg, code) {
299
+ var texts = (cfg && cfg.texts && typeof cfg.texts === 'object' && !Array.isArray(cfg.texts))
300
+ ? cfg.texts : null;
301
+ if (!texts) return null;
302
+ var k = String(code || '').toLowerCase();
303
+ if (!LANG_KEY_RE.test(k)) return null;
304
+ if (!Object.prototype.hasOwnProperty.call(texts, k)) return null;
305
+ var v = texts[k];
306
+ return (v && typeof v === 'object' && !Array.isArray(v)) ? v : null;
307
+ }
308
+
309
+ /* The keys an override may reach. A WHITELIST, not «whatever the object
310
+ carries»: an operator writing their own banner copy has no business
311
+ rewriting «Принять всё» (the button labels are what a visitor recognises
312
+ across sites) or the plural tables (an array here would break plural()).
313
+ `cat.<name>.title/desc` is handled separately below, one level deeper. */
314
+ var OVERRIDE_KEYS = [
315
+ 'bannerTitle', 'bannerText', 'panelTitle', 'panelIntro', 'extraTitle', 'extraText'
316
+ ];
317
+
318
+ // A non-empty string wins; empty or missing falls through to the layer below.
319
+ // That is what makes an empty field in the cabinet mean «take the dictionary»
320
+ // rather than «render nothing».
321
+ function pickOverride(src, key) {
322
+ if (!src) return null;
323
+ if (!Object.prototype.hasOwnProperty.call(src, key)) return null;
324
+ var v = src[key];
325
+ return (typeof v === 'string' && v) ? v : null;
326
+ }
327
+
328
+ /* Deep two-level fill from en: a partial locale must never yield undefined,
329
+ which would render the literal string "undefined".
330
+
331
+ Three layers as of 0.5.15: builtin DICT <- window.__ckLocales (both already
332
+ folded into `table`) <- config.texts[lang]. `cfg` is OPTIONAL on purpose —
333
+ placeholderText() and the cabinet's own calls ask for the plain dictionary,
334
+ and an absent third argument must build exactly what 0.5.14 built. */
335
+ function buildStrings(lang, table, cfg) {
266
336
  var src = table[lang] || {};
267
337
  var base = DICT.en;
268
338
  var out = {};
269
339
  var i, c;
340
+
341
+ /* Exact code first, then the two-letter base of the RESOLVED language:
342
+ a banner that resolved to `pt-br` takes `texts['pt-br']` over
343
+ `texts['pt']`, and a site that only wrote `texts.pt` still reaches it.
344
+ Same shape as resolveLang(), so the override follows the banner. */
345
+ var code = String(lang || '').toLowerCase();
346
+ var ov = configTexts(cfg, code);
347
+ var ovBase = (code.length > 2) ? configTexts(cfg, code.slice(0, 2)) : null;
348
+
349
+ function override(key) {
350
+ return pickOverride(ov, key) || pickOverride(ovBase, key);
351
+ }
352
+
270
353
  for (i = 0; i < STR_KEYS.length; i++) {
271
354
  var k = STR_KEYS[i];
272
355
  out[k] = (typeof src[k] === 'string' && src[k]) ? src[k] : base[k];
@@ -290,9 +373,309 @@
290
373
  desc: (typeof e.desc === 'string' && e.desc) ? e.desc : base.cat[c].desc
291
374
  };
292
375
  }
376
+
377
+ /* SPEC V1.16 §1.1 — the config layer, applied LAST so it wins over both the
378
+ builtin dictionary and __ckLocales. Only the whitelisted keys, and only
379
+ non-empty strings: an override that is absent, empty or not a string
380
+ leaves whatever the two lower layers produced.
381
+
382
+ Nothing here is ever treated as HTML. These strings go through el()'s
383
+ textContent and (for extraText) renderRich(), which builds nodes with
384
+ createElement/createTextNode — an author who writes «<b>» sees the
385
+ characters «<b>», which is the contract the server's validator mirrors. */
386
+ if (ov || ovBase) {
387
+ for (i = 0; i < OVERRIDE_KEYS.length; i++) {
388
+ var ok = OVERRIDE_KEYS[i];
389
+ var hit = override(ok);
390
+ if (hit) out[ok] = hit;
391
+ }
392
+ // cat.<name>.title / cat.<name>.desc, one level deeper. Read from a
393
+ // `cat` object on the override, with the same exact-then-base chain.
394
+ var ovCat = (ov && ov.cat && typeof ov.cat === 'object') ? ov.cat : null;
395
+ var ovBaseCat = (ovBase && ovBase.cat && typeof ovBase.cat === 'object') ? ovBase.cat : null;
396
+ for (i = 0; i < ALL_CATS.length; i++) {
397
+ c = ALL_CATS[i];
398
+ var oe = (ovCat && ovCat[c] && typeof ovCat[c] === 'object') ? ovCat[c] : null;
399
+ var obe = (ovBaseCat && ovBaseCat[c] && typeof ovBaseCat[c] === 'object') ? ovBaseCat[c] : null;
400
+ var t = pickOverride(oe, 'title') || pickOverride(obe, 'title');
401
+ var d = pickOverride(oe, 'desc') || pickOverride(obe, 'desc');
402
+ if (t) out.cat[c].title = t;
403
+ if (d) out.cat[c].desc = d;
404
+ }
405
+ }
406
+ return out;
407
+ }
408
+
409
+ /* ------------------------------------------ rich text (SPEC V1.16 §1.2) */
410
+
411
+ /* The tiny markup subset the «Дополнительно» block accepts. It exists because
412
+ an operator block is genuinely structured text — two or three paragraphs, a
413
+ bold e-mail address, a link to the supervisory authority — and because the
414
+ alternative (accepting HTML) would mean sanitising author HTML in the
415
+ browser, which is a class of bug we are not going to own.
416
+
417
+ THE RULES, in the order they are applied. The server's validator mirrors
418
+ this exactly, so the order is part of the contract, not an implementation
419
+ detail:
420
+
421
+ 1. A blank line (two newlines, any surrounding spaces) splits paragraphs.
422
+ Each paragraph becomes one <p>.
423
+ 2. Inside a paragraph, a single newline becomes <br>.
424
+ 3. LINKS ARE TOKENISED FIRST, before anything else looks at the text:
425
+ [label](URL) with URL http(s):// or mailto:
426
+ a bare https?:// URL
427
+ a bare e-mail address
428
+ Link-first is not a preference — it is what stops the bare-URL rule
429
+ from eating the address inside «[Центр](https://datepersonale.md)»
430
+ and stops the bare-e-mail rule from firing inside a mailto: label.
431
+ 4. `**bold**` is applied only to the TEXT RUNS BETWEEN links. So bold does
432
+ not nest inside a link label and a link does not appear inside bold —
433
+ one pass, no nesting, nothing recursive to get wrong.
434
+ 5. Any other URL scheme in [label](…) — javascript:, data:, file: — is
435
+ NOT a link. The WHOLE literal «[label](javascript:…)» is rendered as
436
+ plain text: visibly wrong to whoever wrote it, rather than silently
437
+ dropping the address and leaving a label that looks like a link.
438
+ 6. There is no HTML. «<b>» is five characters of text, always.
439
+
440
+ Returns an ARRAY of nodes, so the caller decides where they go. Pure apart
441
+ from document.createElement, which is what makes «no innerHTML» mechanical
442
+ rather than a promise. */
443
+
444
+ // [label](url) — the label may not contain brackets, the url may not contain
445
+ // whitespace or a closing paren. Deliberately strict: an address that needs
446
+ // an escaped paren is an address that belongs in a plain bare-URL run.
447
+ var MD_LINK_RE = /\[([^\]\[]+)\]\(([^()\s]+)\)/;
448
+ var BARE_URL_RE = /https?:\/\/[^\s<>()\[\]"']+[^\s<>()\[\]"'.,;:!?]/;
449
+ // Simple on purpose: an address that this misses renders as plain text, which
450
+ // is a worse link, not a security hole. Anything clever here is a false
451
+ // positive waiting to turn a sentence into a mailto:.
452
+ var BARE_MAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+/;
453
+
454
+ // http(s) for an outbound link, mailto: for an address. Everything else is
455
+ // refused — a javascript: or data: URL in a control the visitor is invited to
456
+ // click is the XSS vector resolveDetails() already refuses for policyUrl.
457
+ function richHref(url) {
458
+ var u = str(url);
459
+ if (!u) return null;
460
+ if (/^https?:\/\//i.test(u)) return u;
461
+ if (/^mailto:[^\s]+@[^\s]+$/i.test(u)) return u;
462
+ return null;
463
+ }
464
+
465
+ function richLink(label, href) {
466
+ var a = el('a', 'ck-rich__link', label);
467
+ a.href = href;
468
+ a.target = '_blank';
469
+ a.rel = 'noopener';
470
+ return a;
471
+ }
472
+
473
+ /* `**bold**` over a plain run. Non-greedy and never across a newline, so an
474
+ unclosed «**» is two literal asterisks rather than a bold tail that
475
+ swallows the rest of the paragraph. */
476
+ var BOLD_RE = /\*\*([^*\n]+)\*\*/;
477
+
478
+ /* PASS 3 — bare addresses inside one run of plain text. Runs LAST, on text
479
+ that no longer contains any [label](url), so it cannot reach inside one. */
480
+ function richBare(text, out) {
481
+ var rest = String(text);
482
+ while (rest) {
483
+ var url = BARE_URL_RE.exec(rest);
484
+ var mail = BARE_MAIL_RE.exec(rest);
485
+ // Whichever address starts first. A URL wins a tie: «https://a@b.md» is
486
+ // an address with a userinfo part, not an e-mail sitting after a scheme.
487
+ var m = null, isMail = false;
488
+ if (url) { m = url; }
489
+ if (mail && (!m || mail.index < m.index)) { m = mail; isMail = true; }
490
+ if (!m) { out.push(document.createTextNode(rest)); return; }
491
+ if (m.index > 0) out.push(document.createTextNode(rest.slice(0, m.index)));
492
+ out.push(richLink(m[0], isMail ? ('mailto:' + m[0]) : m[0]));
493
+ rest = rest.slice(m.index + m[0].length);
494
+ }
495
+ }
496
+
497
+ /* PASS 2 — `**bold**` over one run of text, then PASS 3 inside each half.
498
+
499
+ Bold has to come BEFORE the bare-address rules, not after: «**mail@x.md**»
500
+ is one bold run containing an address, and a pass that linked the address
501
+ first would split the run in three and leave BOLD_RE looking at «**» and
502
+ «**» separately — the asterisks would survive on screen. That is the whole
503
+ reason these are three ordered passes rather than one left-to-right walk. */
504
+ function richBold(text, out) {
505
+ var rest = String(text);
506
+ var m;
507
+ while ((m = BOLD_RE.exec(rest))) {
508
+ if (m.index > 0) richBare(rest.slice(0, m.index), out);
509
+ var strong = el('strong', null);
510
+ var inner = [];
511
+ richBare(m[1], inner); // an address inside bold is still a link
512
+ for (var i = 0; i < inner.length; i++) strong.appendChild(inner[i]);
513
+ out.push(strong);
514
+ rest = rest.slice(m.index + m[0].length);
515
+ }
516
+ if (rest) richBare(rest, out);
517
+ }
518
+
519
+ /* PASS 1 — one line of a paragraph -> nodes.
520
+
521
+ `[label](url)` is tokenised first and its pieces never reach passes 2 and 3:
522
+ that is what stops the bare-URL rule from eating the address inside the
523
+ parens, stops the bare-e-mail rule from firing inside a mailto: label, and
524
+ leaves a label's own «**» literal (rule 4 of the contract). The text
525
+ BETWEEN markdown links goes through richBold(), which then runs richBare()
526
+ inside and outside each bold span. */
527
+ function richLine(line, out) {
528
+ var rest = String(line);
529
+ var m;
530
+ while ((m = MD_LINK_RE.exec(rest))) {
531
+ if (m.index > 0) richBold(rest.slice(0, m.index), out);
532
+ var href = richHref(m[2]);
533
+ // Rule 5: a refused scheme is the whole literal, verbatim and unbolded,
534
+ // so «[x](javascript:alert(1))» reads as exactly what was written.
535
+ if (href) out.push(richLink(m[1], href));
536
+ else out.push(document.createTextNode(m[0]));
537
+ rest = rest.slice(m.index + m[0].length);
538
+ }
539
+ if (rest) richBold(rest, out);
540
+ }
541
+
542
+ /* text -> array of <p> nodes. Empty (or non-string) input gives an empty
543
+ array, which is what lets the caller say «no text, no block» by asking for
544
+ the length rather than by re-testing the string. */
545
+ function renderRich(text) {
546
+ var s = (typeof text === 'string') ? text : '';
547
+ if (!s.trim()) return [];
548
+ // \r\n and \r normalised first: a value pasted from Windows or copied out of
549
+ // a textarea must split into the same paragraphs as one typed here.
550
+ var paras = s.replace(/\r\n?/g, '\n').split(/\n[ \t]*\n+/);
551
+ var out = [];
552
+ for (var i = 0; i < paras.length; i++) {
553
+ var para = paras[i].replace(/^\n+|\n+$/g, '');
554
+ if (!para.trim()) continue;
555
+ var p = el('p', 'ck-rich__p');
556
+ var lines = para.split('\n');
557
+ for (var j = 0; j < lines.length; j++) {
558
+ if (j > 0) p.appendChild(el('br')); // rule 2: a single newline
559
+ var nodes = [];
560
+ richLine(lines[j], nodes);
561
+ for (var k = 0; k < nodes.length; k++) p.appendChild(nodes[k]);
562
+ }
563
+ out.push(p);
564
+ }
565
+ return out;
566
+ }
567
+
568
+ /* ------------------------------------------ banner links (SPEC V1.16 §1.3) */
569
+
570
+ /* `texts.links` — up to three of the operator's own addresses, rendered under
571
+ the banner buttons and at the foot of the settings panel. The obvious pair
572
+ is «Политика конфиденциальности» and «Политика cookie», which a Moldovan
573
+ site is required to publish and which have nowhere else to go on the card.
574
+
575
+ Deliberately NOT tangled with `detailsAction`: «Подробнее» keeps doing
576
+ exactly what it did in 0.5.14, because a site that gains a link row must
577
+ not silently lose the link it already had.
578
+
579
+ A row is skipped, never faked, when its address is not http(s) or when no
580
+ label resolves for the banner's language — a link with no words on it is
581
+ not a link. The cap is applied to the SURVIVORS: one malformed row must not
582
+ cost a good one its place. */
583
+ function linkLabel(labels, lang) {
584
+ if (!labels || typeof labels !== 'object' || Array.isArray(labels)) return null;
585
+ var code = (typeof lang === 'string') ? lang.toLowerCase() : '';
586
+ // Own properties only, exactly as branding's pickPoweredByText does it and
587
+ // for the same reason: `labels` is author-supplied JSON, and an inherited
588
+ // key would hand str() a function instead of a missing line.
589
+ function own(k) {
590
+ return (k && Object.prototype.hasOwnProperty.call(labels, k)) ? str(labels[k]) : null;
591
+ }
592
+ var hit = own(code);
593
+ if (!hit && code.length > 2) hit = own(code.slice(0, 2)); // ru-RU -> ru
594
+ return hit || own('en');
595
+ }
596
+
597
+ var MAX_LINKS = 3;
598
+
599
+ function resolveLinks(cfg, lang) {
600
+ var texts = (cfg && cfg.texts && typeof cfg.texts === 'object') ? cfg.texts : null;
601
+ var list = texts && texts.links;
602
+ if (!list || !Array.isArray(list)) return [];
603
+ var out = [];
604
+ for (var i = 0; i < list.length && out.length < MAX_LINKS; i++) {
605
+ var row = list[i];
606
+ if (!row || typeof row !== 'object') continue;
607
+ var url = str(row.url);
608
+ if (!url || !/^https?:\/\//i.test(url)) continue;
609
+ var label = linkLabel(row.label, lang);
610
+ if (!label) continue;
611
+ out.push({ id: str(row.id) || ('link' + i), url: url, label: label });
612
+ }
293
613
  return out;
294
614
  }
295
615
 
616
+ /* SPEC V1.16 §1.3, last sentence — the one place where the link row DOES
617
+ touch «Подробнее»: when the address it would send the visitor to is
618
+ already one of the rows under the buttons, the banner shows the same
619
+ address twice. The second copy is not more information, it is noise, so
620
+ the in-text link is dropped and the banner renders as `detailsAction:
621
+ 'hide'` for that render — the link row keeps the address, with a label the
622
+ operator wrote, which is the better of the two.
623
+
624
+ Only 'policy' and 'declaration' can duplicate anything: 'settings' opens
625
+ the panel and has no address to collide with, and 'hide' is already gone.
626
+
627
+ Comparison is deliberately narrow. Trimming and a trailing slash are
628
+ typography, not identity — `https://shop.md/cookies` and
629
+ `…/cookies/` are the same page — and so is the case of the HOST, which
630
+ DNS does not distinguish. The PATH is left alone: `/Cookies` and
631
+ `/cookies` are genuinely two addresses on a case-sensitive server, and
632
+ suppressing a real «Подробнее» link over that guess is the worse error. */
633
+ function normHref(v) {
634
+ var s = str(v);
635
+ if (!s) return null;
636
+ var m = /^(https?:\/\/[^\/?#]*)([\s\S]*)$/i.exec(s);
637
+ if (!m) return null;
638
+ return (m[1].toLowerCase() + m[2]).replace(/\/$/, '');
639
+ }
640
+
641
+ function detailsDuplicatesLink(details, links) {
642
+ if (!details) return false;
643
+ if (details.kind !== 'policy' && details.kind !== 'declaration') return false;
644
+ var href = normHref(details.href);
645
+ if (!href || !links || !Array.isArray(links)) return false;
646
+ for (var i = 0; i < links.length; i++) {
647
+ if (links[i] && normHref(links[i].url) === href) return true;
648
+ }
649
+ return false;
650
+ }
651
+
652
+ /* Which of the three shapes the banner will actually draw, duplicate rule
653
+ applied — so buildBanner() and signature() cannot disagree about it.
654
+ `lang` is the code the banner resolved to; signature() passes the config's
655
+ own so both sides of a comparison are read in the same language. */
656
+ function detailsKind(cfg, lang) {
657
+ var det = resolveDetails(cfg);
658
+ var code = (typeof lang === 'string' && lang) ? lang :
659
+ resolveLang(cfg && cfg.language, localeTable());
660
+ return detailsDuplicatesLink(det, resolveLinks(cfg, code)) ? 'hide' : det.kind;
661
+ }
662
+
663
+ // The row itself, or null when there is nothing to draw — so both call sites
664
+ // (banner and panel) are one `if` rather than two.
665
+ function buildLinksRow(cfg, cls) {
666
+ var links = resolveLinks(cfg, LANG);
667
+ if (!links.length) return null;
668
+ var row = el('div', 'ck-links' + (cls ? ' ' + cls : ''));
669
+ for (var i = 0; i < links.length; i++) {
670
+ var a = el('a', 'ck-links__a', links[i].label);
671
+ a.href = links[i].url;
672
+ a.target = '_blank';
673
+ a.rel = 'noopener';
674
+ row.appendChild(a);
675
+ }
676
+ return row;
677
+ }
678
+
296
679
  /* ------------------------------------------- blocked-embed placeholder (§2) */
297
680
 
298
681
  /* The one piece of the placeholder that is worth testing without a DOM: which
@@ -537,6 +920,40 @@
537
920
  >= 4.5:1 rule as «Подробнее» — see the note on the `a{}` rule above. */
538
921
  '.ck-svc__policy{display:inline-block;margin-top:4px;font-size:12.5px;color:var(--ck-link)}',
539
922
 
923
+ /* ---- «Дополнительно» card (SPEC V1.16 §1.2) ---- */
924
+ /* A card, not another category row: it carries no switch and answers a
925
+ different question — who the operator is and how to reach them. So it
926
+ gets the soft fill and the rounded border the cookie table wears, which
927
+ reads as «a panel of reference text» rather than «a fifth thing you can
928
+ turn off».
929
+
930
+ It is appended AFTER the categories, which means the last .ck-cat is no
931
+ longer `:last-child` and keeps its bottom rule — that is wanted here: the
932
+ rule is what separates the last category from this block. The rule below
933
+ restores the old look for the category above it only in the sense that
934
+ the separator now belongs to the boundary rather than to the group. */
935
+ '.ck-extra{margin:16px 0 4px;padding:14px 16px;background:var(--ck-soft);',
936
+ 'border:1px solid var(--ck-line);border-radius:var(--ck-radius-card)}',
937
+ '.ck-extra__title{margin:0 0 6px;font-size:15px;font-weight:600}',
938
+ /* Same size and colour as .ck-cat__desc: this is secondary reference text,
939
+ and it is measured for AA against the card by the same rule. */
940
+ '.ck-extra__body p{margin:0 0 8px;font-size:13.5px;color:var(--ck-muted);line-height:1.5}',
941
+ '.ck-extra__body p:last-child{margin-bottom:0}',
942
+ /* --ck-link, not --ck-accent — accent-coloured TEXT answers to 4.5:1
943
+ against the card; see the note on the `a{}` rule above. */
944
+ '.ck-extra__body a{color:var(--ck-link)}',
945
+
946
+ /* ---- link row under the buttons (SPEC V1.16 §1.3) ---- */
947
+ /* One row, wrapping. `width:100%` matters in the bar layout, whose banner is
948
+ a flex row: without it the row would try to sit BESIDE the buttons on a
949
+ wide screen instead of under them. */
950
+ '.ck-links{display:flex;gap:14px;flex-wrap:wrap;width:100%;margin-top:12px;font-size:13px}',
951
+ '.ck-links__a{color:var(--ck-link)}',
952
+ /* The panel foot is a flex row of buttons that each take `flex:1 1 150px`;
953
+ a full-width row forced onto its own line keeps the links under the
954
+ buttons rather than wedged between two of them. */
955
+ '.ck-links--panel{flex-basis:100%;margin-top:2px}',
956
+
540
957
  /* ---- switch ---- */
541
958
  '.ck-switch{flex:none;width:46px;height:27px;padding:0;border-radius:999px;',
542
959
  'border:1px solid var(--ck-line);background:var(--ck-soft);position:relative}',
@@ -585,6 +1002,9 @@
585
1002
  '.ck-banner--bar .ck-banner__body{display:contents}',
586
1003
  '.ck-banner--bar .ck-banner__body>*{order:1}',
587
1004
  '.ck-banner--bar .ck-actions{order:2}',
1005
+ // SPEC V1.16 §1.3 — the link row is `order:3` so it stays under the buttons
1006
+ // in the bar's mobile column, where every child's position is explicit.
1007
+ '.ck-banner--bar .ck-links{order:3}',
588
1008
  /* Reference: the box's button row becomes a column under 560px. */
589
1009
  '.ck-banner--box .ck-actions{flex-direction:column}',
590
1010
  '.ck-banner--box .ck-btn{flex:1 1 auto;width:100%}}',
@@ -2088,6 +2508,14 @@
2088
2508
 
2089
2509
  var p = el('p');
2090
2510
  var det = resolveDetails(cfg);
2511
+ /* SPEC V1.16 §1.3 — «Подробнее» and one of the link rows point at the same
2512
+ page: drop the in-text copy rather than print the address twice. Asked
2513
+ through detailsKind() so the shape drawn here is the same one signature()
2514
+ watches for changes. The links are resolved for THIS render's language,
2515
+ exactly as buildLinksRow does below, so a row that no visitor of this
2516
+ banner can see (no label in their language) never silences the link
2517
+ they can. */
2518
+ if (detailsKind(cfg, LANG) === 'hide') det = { kind: 'hide', href: null };
2091
2519
  if (det.kind === 'hide') {
2092
2520
  p.appendChild(document.createTextNode(T.bannerText));
2093
2521
  } else {
@@ -2148,6 +2576,18 @@
2148
2576
  actions.appendChild(custom);
2149
2577
  b.appendChild(actions);
2150
2578
 
2579
+ /* SPEC V1.16 §1.3 — the link row, under the buttons in all three layouts.
2580
+ Appended to the BANNER rather than to the body, so it sits after the
2581
+ actions in DOM (and reading) order everywhere: box and modal stack, and
2582
+ the bar's `width:100%` on .ck-links forces it onto its own line under
2583
+ the row instead of squeezing in beside the buttons.
2584
+
2585
+ The bar's mobile media query re-orders its children explicitly (body
2586
+ contents `order:1`, actions `order:2`); the row carries `order:3` there
2587
+ so it stays below the buttons rather than jumping above them. */
2588
+ var blinks = buildLinksRow(cfg, 'ck-links--banner');
2589
+ if (blinks) b.appendChild(blinks);
2590
+
2151
2591
  // box/modal: the foot follows the buttons (see the note above).
2152
2592
  if (foot && lay.type !== 'bar') b.appendChild(foot);
2153
2593
 
@@ -2159,6 +2599,21 @@
2159
2599
  root.appendChild(b);
2160
2600
  }
2161
2601
 
2602
+ /* SPEC V1.16 §1.2 — the «Дополнительно» card, or null when the site supplied
2603
+ no text for this language. T.extraText has already been through the whole
2604
+ merge (dictionary '' <- __ckLocales <- config.texts[lang] <- [base]), so
2605
+ the only question left here is whether anything survived it. */
2606
+ function buildExtra() {
2607
+ var nodes = renderRich(T.extraText);
2608
+ if (!nodes.length) return null;
2609
+ var box = el('div', 'ck-extra');
2610
+ box.appendChild(el('h3', 'ck-extra__title', T.extraTitle));
2611
+ var bodyEl = el('div', 'ck-extra__body');
2612
+ for (var i = 0; i < nodes.length; i++) bodyEl.appendChild(nodes[i]);
2613
+ box.appendChild(bodyEl);
2614
+ return box;
2615
+ }
2616
+
2162
2617
  function buildPanel(cfg) {
2163
2618
  var scrim = el('div', 'ck-panel-scrim ck-hidden');
2164
2619
  scrim.setAttribute('aria-hidden', 'true');
@@ -2186,6 +2641,18 @@
2186
2641
  var body = el('div', 'ck-panel__body');
2187
2642
  var cats = ['necessary'].concat(activeOptIn(cfg));
2188
2643
  for (var i = 0; i < cats.length; i++) body.appendChild(buildCategory(cfg, cats[i]));
2644
+
2645
+ /* SPEC V1.16 §1.2 — «Дополнительно», after the categories and their service
2646
+ lists, before the buttons. INSIDE .ck-panel__body, which is the scrolling
2647
+ region: an operator block runs to a paragraph or three, and put between
2648
+ the body and the foot it would be clipped on a short viewport with no way
2649
+ to reach the end of it.
2650
+
2651
+ Rendered only when the resolved extraText is a non-empty string — every
2652
+ dictionary ships '', so a site that never configured one draws nothing
2653
+ and the panel is byte-for-byte what 0.5.14 drew. */
2654
+ var extra = buildExtra();
2655
+ if (extra) body.appendChild(extra);
2189
2656
  p.appendChild(body);
2190
2657
 
2191
2658
  var foot = el('div', 'ck-panel__foot');
@@ -2213,6 +2680,11 @@
2213
2680
  foot.appendChild(save);
2214
2681
  foot.appendChild(acc);
2215
2682
  foot.appendChild(rej);
2683
+ // SPEC V1.16 §1.3 — the operator's own addresses, under the panel's buttons
2684
+ // and above the attribution, so the bottom of the panel reads: what you can
2685
+ // do, then where to read more, then who made this.
2686
+ var plinks = buildLinksRow(cfg, 'ck-links--panel');
2687
+ if (plinks) foot.appendChild(plinks);
2216
2688
  // Same attribution foot as the banner: mark and credit sign the bottom,
2217
2689
  // below the action buttons, never the panel heading.
2218
2690
  var pfoot = buildPoweredBy(cfg);
@@ -2769,7 +3241,14 @@
2769
3241
  // Structural: link / button / nothing are three different DOM shapes,
2770
3242
  // and mount() is one-shot. Buttons and colours are deliberately NOT
2771
3243
  // here — they are token values and restyle in place.
2772
- resolveDetails(c).kind,
3244
+ /* SPEC V1.16 §1.3 — the EFFECTIVE kind, not the configured one: a config
3245
+ whose policyUrl grows into (or out of) one of its own link rows flips
3246
+ the shape between link and nothing while resolveDetails still answers
3247
+ 'policy'. Without the fold, a SaaS config arriving after the first
3248
+ mount would leave the duplicate on screen. The language is the config's
3249
+ own, resolved the way mount() resolves it, so the two sides of a
3250
+ comparison are always read in the same language. */
3251
+ detailsKind(c),
2773
3252
  /* SPEC V1.12 §3 — services are STRUCTURAL: each one adds a row with its
2774
3253
  own switch to the panel, and mount() is one-shot. Without this a SaaS
2775
3254
  config that arrives after the first mount (the second, idempotent
@@ -2782,10 +3261,43 @@
2782
3261
  `purpose` is text inside an existing row and does not justify tearing
2783
3262
  the panel down. */
2784
3263
  serviceSignature(c),
3264
+ /* SPEC V1.16 §1 — custom texts are STRUCTURAL for the same reason
3265
+ services are: the «Дополнительно» card and the link row are DOM nodes
3266
+ that appear and disappear, and mount() is one-shot. A SaaS config that
3267
+ arrives after the first render (the second, idempotent init()) would
3268
+ otherwise leave a panel with no operator block on a site that has one.
3269
+
3270
+ Not the texts themselves, which can be long: the LANGUAGE KEYS that
3271
+ carry overrides, whether an extra block would render, and the links'
3272
+ identity. A reworded sentence inside a block that is already on screen
3273
+ does not justify tearing the panel down mid-decision. */
3274
+ textsSignature(c),
2785
3275
  brandSignature(c)
2786
3276
  ].join('|');
2787
3277
  }
2788
3278
 
3279
+ function textsSignature(cfg) {
3280
+ var texts = (cfg && cfg.texts && typeof cfg.texts === 'object' && !Array.isArray(cfg.texts))
3281
+ ? cfg.texts : null;
3282
+ if (!texts) return '0';
3283
+ var langs = [];
3284
+ for (var k in texts) {
3285
+ if (!Object.prototype.hasOwnProperty.call(texts, k)) continue;
3286
+ if (LANG_KEY_RE.test(String(k).toLowerCase())) langs.push(String(k).toLowerCase());
3287
+ }
3288
+ langs.sort();
3289
+ var list = texts.links;
3290
+ var ids = [];
3291
+ if (Array.isArray(list)) {
3292
+ for (var i = 0; i < list.length; i++) {
3293
+ var row = list[i];
3294
+ if (row && typeof row === 'object') ids.push(String(row.id || i) + ':' + String(row.url || ''));
3295
+ }
3296
+ }
3297
+ if (!langs.length && !ids.length) return '0';
3298
+ return langs.join(',') + '/' + ids.join(',');
3299
+ }
3300
+
2789
3301
  function serviceSignature(cfg) {
2790
3302
  var list = cfg && cfg.services;
2791
3303
  if (!list || !Array.isArray(list) || !list.length) return '0';
@@ -2828,7 +3340,10 @@
2828
3340
 
2829
3341
  var table = localeTable(); // read at render time
2830
3342
  LANG = resolveLang(cfg && cfg.language, table);
2831
- T = buildStrings(LANG, table);
3343
+ // SPEC V1.16 §1.1 — the config is the third merge layer, so it must be
3344
+ // handed in here: this is the only place that knows which language the
3345
+ // banner actually resolved to, which is what the override is keyed by.
3346
+ T = buildStrings(LANG, table, cfg);
2832
3347
 
2833
3348
  host = document.getElementById('ck-root');
2834
3349
  if (!host) {
@@ -2964,6 +3479,22 @@
2964
3479
  servicePurpose: servicePurpose,
2965
3480
  groupCountLabel: groupCountLabel,
2966
3481
  serviceSignature: serviceSignature,
3482
+ /* SPEC V1.16 §1 — the pure halves of the custom-text feature, so the
3483
+ cabinet's live preview quotes the same rules the banner paints and the
3484
+ server's validator can be tested against the same contract.
3485
+
3486
+ renderRich() is the exception to «nothing here touches the DOM»: it
3487
+ BUILDS nodes, so it needs document.createElement. That is deliberate —
3488
+ the whole point of the function is that there is no innerHTML anywhere
3489
+ in it, and a version that returned a string could not promise that. It
3490
+ is still pure in every other sense: same input, same nodes, no reads of
3491
+ module state. In a context with no `document` it is simply not called. */
3492
+ renderRich: renderRich,
3493
+ resolveLinks: resolveLinks,
3494
+ linkLabel: linkLabel,
3495
+ detailsDuplicatesLink: detailsDuplicatesLink,
3496
+ detailsKind: detailsKind,
3497
+ textsSignature: textsSignature,
2967
3498
  signature: signature
2968
3499
  };
2969
3500
  // The page-font probe reads the DOM, so it is not part of the pure block —