@mccaskie-labs/workbench-config 0.1.16 → 0.1.18

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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @mccaskie-labs/workbench-config
2
2
 
3
+ ## 0.1.18
4
+
5
+ ### Patch Changes
6
+
7
+ - The `strings` component: `capabilities/strings.json` (rules and probes for `@mccaskie-labs/workbench-strings`) joins the manifests, and `strings` joins the component list.
8
+
9
+ ## 0.1.17
10
+
11
+ ### Patch Changes
12
+
13
+ - Capabilities: the `pdf` component (workbench-pdf) with its `pdf.document` and `pdf.html` probes.
14
+
3
15
  ## 0.1.16
4
16
 
5
17
  ### Patch Changes
@@ -0,0 +1,93 @@
1
+ {
2
+ "$schema": "./schema.json",
3
+ "format": 1,
4
+ "component": "pdf",
5
+ "title": "Branded PDFs",
6
+ "summary": "workbench-pdf prints an app's content as a PDF with its brand.json logo, colours and fonts, in the app's own Worker through its Browser Rendering binding (BROWSER). Nothing goes through the Gateway and the app holds no Cloudflare token. The standard layout takes plain-text blocks; html() takes the app's own markup.",
7
+ "status": "built",
8
+ "surfaces": {
9
+ "package": [
10
+ "createPdfMaker",
11
+ "WorkbenchPdfError"
12
+ ]
13
+ },
14
+ "limits": [
15
+ {
16
+ "id": "pdf.blocks",
17
+ "value": 2000,
18
+ "unit": "blocks",
19
+ "per": "document",
20
+ "enforced_by": "types"
21
+ },
22
+ {
23
+ "id": "pdf.text",
24
+ "value": 20000,
25
+ "unit": "characters",
26
+ "per": "text",
27
+ "enforced_by": "types"
28
+ },
29
+ {
30
+ "id": "pdf.logo-size",
31
+ "value": 1048576,
32
+ "unit": "bytes",
33
+ "per": "logo",
34
+ "enforced_by": "types"
35
+ },
36
+ {
37
+ "id": "pdf.time",
38
+ "value": 120000,
39
+ "unit": "ms",
40
+ "per": "document",
41
+ "enforced_by": "types"
42
+ }
43
+ ],
44
+ "rules": [
45
+ {
46
+ "id": "pdf.brand-file",
47
+ "kind": "must",
48
+ "text": "Brand every PDF from the app's brand.json through defineBrand, the same file the account emails use.",
49
+ "enforced_by": "types",
50
+ "probe": "pdf.document"
51
+ },
52
+ {
53
+ "id": "pdf.own-binding",
54
+ "kind": "must_not",
55
+ "text": "Hold a Cloudflare token for Browser Rendering or send PDFs through the Gateway (hard rules 1 and 2): print through the Worker's own BROWSER binding.",
56
+ "enforced_by": "review",
57
+ "probe": "pdf.document"
58
+ },
59
+ {
60
+ "id": "pdf.no-page-network",
61
+ "kind": "must_not",
62
+ "text": "Let a printed page run script or load anything but data: URIs, the brand's font hosts and the origins the app lists in allowOrigins.",
63
+ "enforced_by": "types",
64
+ "probe": "pdf.html"
65
+ }
66
+ ],
67
+ "examples": [
68
+ {
69
+ "id": "pdf.example.document",
70
+ "title": "Print a branded invoice",
71
+ "lang": "ts",
72
+ "probe": "pdf.document",
73
+ "code": "import { defineBrand } from '@mccaskie-labs/workbench-config'\nimport { createPdfMaker } from '@mccaskie-labs/workbench-pdf'\nimport brandFile from '../brand.json'\n\nconst pdf = createPdfMaker({ browser: env.BROWSER, brand: defineBrand(brandFile) })\nconst bytes = await pdf.document({ title: 'Invoice 42', blocks: [{ type: 'table', columns: ['Item', 'Amount'], rows: [['Probe run', '₱112.50']] }] })\nreturn new Response(bytes, { headers: { 'content-type': 'application/pdf' } })"
74
+ }
75
+ ],
76
+ "probes": [
77
+ {
78
+ "id": "pdf.document",
79
+ "half": "runtime",
80
+ "expect": "pass",
81
+ "smoke": true,
82
+ "cost_hint_usd": 0,
83
+ "note": "Prints the standard layout from the app's brand.json through the BROWSER binding; checks the bytes are a PDF and the page carried the brand name"
84
+ },
85
+ {
86
+ "id": "pdf.html",
87
+ "half": "runtime",
88
+ "expect": "pass",
89
+ "cost_hint_usd": 0,
90
+ "note": "Prints the app's own HTML holding an image from a host not in allowOrigins; the PDF is made and the browser reports that image's request as blocked"
91
+ }
92
+ ]
93
+ }
@@ -27,10 +27,12 @@
27
27
  "dns",
28
28
  "analytics",
29
29
  "country",
30
+ "pdf",
30
31
  "access",
31
32
  "r2",
32
33
  "kv",
33
- "github"
34
+ "github",
35
+ "strings"
34
36
  ]
35
37
  },
36
38
  "title": {
@@ -0,0 +1,131 @@
1
+ {
2
+ "$schema": "./schema.json",
3
+ "format": 1,
4
+ "component": "strings",
5
+ "title": "Words on screens and in emails",
6
+ "summary": "workbench-strings holds the words an app's own screens and emails say: one file per language in the app's repository, looked up on the server with named placeholders and plurals, one fallback chain to the base language, language detection with country before the browser's language, back-translation notes stripped at build, and a review gate so no unreviewed translation is inferred for anyone. The standard's tests ship as a kit.",
7
+ "status": "built",
8
+ "surfaces": {
9
+ "package": [
10
+ "defineStrings",
11
+ "resolveLocale",
12
+ "stripGlosses",
13
+ "WORKBENCH_LANGUAGES",
14
+ "WorkbenchStringsError"
15
+ ]
16
+ },
17
+ "rules": [
18
+ {
19
+ "id": "strings.server-only",
20
+ "kind": "must_not",
21
+ "text": "Import workbench-strings into browser code: the catalogue holds every language. Look words up on the server and pass the finished words to the page; the package's browser export throws.",
22
+ "enforced_by": "types",
23
+ "probe": "strings.lookup"
24
+ },
25
+ {
26
+ "id": "strings.no-literals",
27
+ "kind": "must_not",
28
+ "text": "Write user-visible words as literals in code, or keep a key nothing renders. Every word is a key in strings/strings.<base>.json, and the test kit's dead-key sweep fails on an unused one.",
29
+ "enforced_by": "review",
30
+ "probe": "strings.kit"
31
+ },
32
+ {
33
+ "id": "strings.one-chain",
34
+ "kind": "must",
35
+ "text": "Use strings.t and strings.email for every lookup, so screens and emails share one fallback chain: the locale, its parent, then the base. A word found further down the chain is logged.",
36
+ "enforced_by": "types",
37
+ "probe": "strings.lookup"
38
+ },
39
+ {
40
+ "id": "strings.country-before-header",
41
+ "kind": "must",
42
+ "text": "Detect the language in the order URL prefix, cookie, account, country, Accept-Language, default, with strings.detect. Country comes before the browser's language because phones report English whatever their owner reads.",
43
+ "enforced_by": "types",
44
+ "probe": "strings.detect"
45
+ },
46
+ {
47
+ "id": "strings.review-gate",
48
+ "kind": "must_not",
49
+ "text": "Serve a translated language to someone who did not choose it until its catalogue has _review.signed_off naming the reviewer and the date; country, header and default resolve to the base until then.",
50
+ "enforced_by": "types",
51
+ "probe": "strings.gate"
52
+ },
53
+ {
54
+ "id": "strings.named-reviewer",
55
+ "kind": "must_not",
56
+ "text": "Put a translated language live in production without _review.reviewer naming a native-speaker reviewer and their cadence; defineStrings refuses to start.",
57
+ "enforced_by": "types",
58
+ "probe": "strings.no-reviewer"
59
+ },
60
+ {
61
+ "id": "strings.email-missing-throws",
62
+ "kind": "must",
63
+ "text": "Treat an email word missing in the recipient's language and in the base as an error: strings.email throws missing_string rather than sending a key.",
64
+ "enforced_by": "types",
65
+ "probe": "strings.email-missing"
66
+ },
67
+ {
68
+ "id": "strings.glosses-stripped",
69
+ "kind": "must",
70
+ "text": "Keep a _gloss back-translation beside every translated string in the authored file, and ship only the generated file from npx workbench-strings build, which strips them; defineStrings refuses a catalogue with glosses.",
71
+ "enforced_by": "types",
72
+ "probe": "strings.kit"
73
+ }
74
+ ],
75
+ "examples": [
76
+ {
77
+ "id": "strings.example.lookup",
78
+ "title": "Detect the language and look words up",
79
+ "lang": "ts",
80
+ "probe": "strings.lookup",
81
+ "code": "// SERVER ONLY: the catalogue holds every language.\nimport { defineStrings } from '@mccaskie-labs/workbench-strings'\nimport enAU from '../strings/strings.en-AU.json'\nimport enPH from '../strings/runtime/strings.en-PH.json'\n\nconst strings = defineStrings({ env: workbenchEnv(env), base: { locale: 'en-AU', strings: enAU }, translations: { 'en-PH': enPH }, locales: ['en-PH', 'en-AU'], defaultLocale: 'en-PH' })\nconst { locale } = strings.detect(request, { account: user?.locale })\nconst title = strings.t(locale, 'ui.cart.count', { count: 3 })"
82
+ }
83
+ ],
84
+ "probes": [
85
+ {
86
+ "id": "strings.lookup",
87
+ "half": "runtime",
88
+ "expect": "pass",
89
+ "smoke": true,
90
+ "cost_hint_usd": 0,
91
+ "note": "Placeholders and a plural fill in each live locale, and a word missing in a translation comes from the base and is logged"
92
+ },
93
+ {
94
+ "id": "strings.detect",
95
+ "half": "runtime",
96
+ "expect": "pass",
97
+ "cost_hint_usd": 0,
98
+ "note": "Each step of the order wins over the next; a PH visitor whose browser says en-US gets the PH locale; XX falls through to the header"
99
+ },
100
+ {
101
+ "id": "strings.gate",
102
+ "half": "runtime",
103
+ "expect": "pass",
104
+ "cost_hint_usd": 0,
105
+ "note": "An unsigned translation is not inferred (gated: true, base served) but is served from the cookie or prefix"
106
+ },
107
+ {
108
+ "id": "strings.no-reviewer",
109
+ "half": "runtime",
110
+ "expect": "refused_as_expected",
111
+ "refusal": "no_reviewer",
112
+ "cost_hint_usd": 0,
113
+ "note": "defineStrings with env production and a translation with no _review.reviewer"
114
+ },
115
+ {
116
+ "id": "strings.email-missing",
117
+ "half": "runtime",
118
+ "expect": "refused_as_expected",
119
+ "refusal": "missing_string",
120
+ "cost_hint_usd": 0,
121
+ "note": "strings.email for a part no catalogue has"
122
+ },
123
+ {
124
+ "id": "strings.kit",
125
+ "half": "runtime",
126
+ "expect": "pass",
127
+ "cost_hint_usd": 0,
128
+ "note": "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI"
129
+ }
130
+ ]
131
+ }
@@ -1,4 +1,4 @@
1
- export declare const MANIFEST_VERSION: "c72ad5ff6960";
1
+ export declare const MANIFEST_VERSION: "d0231144218c";
2
2
  export declare const capabilities: {
3
3
  readonly ai: {
4
4
  readonly $schema: "./schema.json";
@@ -1442,6 +1442,189 @@ export declare const capabilities: {
1442
1442
  readonly cost_hint_usd: 0;
1443
1443
  }];
1444
1444
  };
1445
+ readonly pdf: {
1446
+ readonly $schema: "./schema.json";
1447
+ readonly format: 1;
1448
+ readonly component: "pdf";
1449
+ readonly title: "Branded PDFs";
1450
+ readonly summary: "workbench-pdf prints an app's content as a PDF with its brand.json logo, colours and fonts, in the app's own Worker through its Browser Rendering binding (BROWSER). Nothing goes through the Gateway and the app holds no Cloudflare token. The standard layout takes plain-text blocks; html() takes the app's own markup.";
1451
+ readonly status: "built";
1452
+ readonly surfaces: {
1453
+ readonly package: readonly ["createPdfMaker", "WorkbenchPdfError"];
1454
+ };
1455
+ readonly limits: readonly [{
1456
+ readonly id: "pdf.blocks";
1457
+ readonly value: 2000;
1458
+ readonly unit: "blocks";
1459
+ readonly per: "document";
1460
+ readonly enforced_by: "types";
1461
+ }, {
1462
+ readonly id: "pdf.text";
1463
+ readonly value: 20000;
1464
+ readonly unit: "characters";
1465
+ readonly per: "text";
1466
+ readonly enforced_by: "types";
1467
+ }, {
1468
+ readonly id: "pdf.logo-size";
1469
+ readonly value: 1048576;
1470
+ readonly unit: "bytes";
1471
+ readonly per: "logo";
1472
+ readonly enforced_by: "types";
1473
+ }, {
1474
+ readonly id: "pdf.time";
1475
+ readonly value: 120000;
1476
+ readonly unit: "ms";
1477
+ readonly per: "document";
1478
+ readonly enforced_by: "types";
1479
+ }];
1480
+ readonly rules: readonly [{
1481
+ readonly id: "pdf.brand-file";
1482
+ readonly kind: "must";
1483
+ readonly text: "Brand every PDF from the app's brand.json through defineBrand, the same file the account emails use.";
1484
+ readonly enforced_by: "types";
1485
+ readonly probe: "pdf.document";
1486
+ }, {
1487
+ readonly id: "pdf.own-binding";
1488
+ readonly kind: "must_not";
1489
+ readonly text: "Hold a Cloudflare token for Browser Rendering or send PDFs through the Gateway (hard rules 1 and 2): print through the Worker's own BROWSER binding.";
1490
+ readonly enforced_by: "review";
1491
+ readonly probe: "pdf.document";
1492
+ }, {
1493
+ readonly id: "pdf.no-page-network";
1494
+ readonly kind: "must_not";
1495
+ readonly text: "Let a printed page run script or load anything but data: URIs, the brand's font hosts and the origins the app lists in allowOrigins.";
1496
+ readonly enforced_by: "types";
1497
+ readonly probe: "pdf.html";
1498
+ }];
1499
+ readonly examples: readonly [{
1500
+ readonly id: "pdf.example.document";
1501
+ readonly title: "Print a branded invoice";
1502
+ readonly lang: "ts";
1503
+ readonly code: "import { defineBrand } from '@mccaskie-labs/workbench-config'\nimport { createPdfMaker } from '@mccaskie-labs/workbench-pdf'\nimport brandFile from '../brand.json'\n\nconst pdf = createPdfMaker({ browser: env.BROWSER, brand: defineBrand(brandFile) })\nconst bytes = await pdf.document({ title: 'Invoice 42', blocks: [{ type: 'table', columns: ['Item', 'Amount'], rows: [['Probe run', '₱112.50']] }] })\nreturn new Response(bytes, { headers: { 'content-type': 'application/pdf' } })";
1504
+ readonly probe: "pdf.document";
1505
+ }];
1506
+ readonly probes: readonly [{
1507
+ readonly id: "pdf.document";
1508
+ readonly half: "runtime";
1509
+ readonly expect: "pass";
1510
+ readonly smoke: true;
1511
+ readonly cost_hint_usd: 0;
1512
+ readonly note: "Prints the standard layout from the app's brand.json through the BROWSER binding; checks the bytes are a PDF and the page carried the brand name";
1513
+ }, {
1514
+ readonly id: "pdf.html";
1515
+ readonly half: "runtime";
1516
+ readonly expect: "pass";
1517
+ readonly cost_hint_usd: 0;
1518
+ readonly note: "Prints the app's own HTML holding an image from a host not in allowOrigins; the PDF is made and the browser reports that image's request as blocked";
1519
+ }];
1520
+ };
1521
+ readonly strings: {
1522
+ readonly $schema: "./schema.json";
1523
+ readonly format: 1;
1524
+ readonly component: "strings";
1525
+ readonly title: "Words on screens and in emails";
1526
+ readonly summary: "workbench-strings holds the words an app's own screens and emails say: one file per language in the app's repository, looked up on the server with named placeholders and plurals, one fallback chain to the base language, language detection with country before the browser's language, back-translation notes stripped at build, and a review gate so no unreviewed translation is inferred for anyone. The standard's tests ship as a kit.";
1527
+ readonly status: "built";
1528
+ readonly surfaces: {
1529
+ readonly package: readonly ["defineStrings", "resolveLocale", "stripGlosses", "WORKBENCH_LANGUAGES", "WorkbenchStringsError"];
1530
+ };
1531
+ readonly rules: readonly [{
1532
+ readonly id: "strings.server-only";
1533
+ readonly kind: "must_not";
1534
+ readonly text: "Import workbench-strings into browser code: the catalogue holds every language. Look words up on the server and pass the finished words to the page; the package's browser export throws.";
1535
+ readonly enforced_by: "types";
1536
+ readonly probe: "strings.lookup";
1537
+ }, {
1538
+ readonly id: "strings.no-literals";
1539
+ readonly kind: "must_not";
1540
+ readonly text: "Write user-visible words as literals in code, or keep a key nothing renders. Every word is a key in strings/strings.<base>.json, and the test kit's dead-key sweep fails on an unused one.";
1541
+ readonly enforced_by: "review";
1542
+ readonly probe: "strings.kit";
1543
+ }, {
1544
+ readonly id: "strings.one-chain";
1545
+ readonly kind: "must";
1546
+ readonly text: "Use strings.t and strings.email for every lookup, so screens and emails share one fallback chain: the locale, its parent, then the base. A word found further down the chain is logged.";
1547
+ readonly enforced_by: "types";
1548
+ readonly probe: "strings.lookup";
1549
+ }, {
1550
+ readonly id: "strings.country-before-header";
1551
+ readonly kind: "must";
1552
+ readonly text: "Detect the language in the order URL prefix, cookie, account, country, Accept-Language, default, with strings.detect. Country comes before the browser's language because phones report English whatever their owner reads.";
1553
+ readonly enforced_by: "types";
1554
+ readonly probe: "strings.detect";
1555
+ }, {
1556
+ readonly id: "strings.review-gate";
1557
+ readonly kind: "must_not";
1558
+ readonly text: "Serve a translated language to someone who did not choose it until its catalogue has _review.signed_off naming the reviewer and the date; country, header and default resolve to the base until then.";
1559
+ readonly enforced_by: "types";
1560
+ readonly probe: "strings.gate";
1561
+ }, {
1562
+ readonly id: "strings.named-reviewer";
1563
+ readonly kind: "must_not";
1564
+ readonly text: "Put a translated language live in production without _review.reviewer naming a native-speaker reviewer and their cadence; defineStrings refuses to start.";
1565
+ readonly enforced_by: "types";
1566
+ readonly probe: "strings.no-reviewer";
1567
+ }, {
1568
+ readonly id: "strings.email-missing-throws";
1569
+ readonly kind: "must";
1570
+ readonly text: "Treat an email word missing in the recipient's language and in the base as an error: strings.email throws missing_string rather than sending a key.";
1571
+ readonly enforced_by: "types";
1572
+ readonly probe: "strings.email-missing";
1573
+ }, {
1574
+ readonly id: "strings.glosses-stripped";
1575
+ readonly kind: "must";
1576
+ readonly text: "Keep a _gloss back-translation beside every translated string in the authored file, and ship only the generated file from npx workbench-strings build, which strips them; defineStrings refuses a catalogue with glosses.";
1577
+ readonly enforced_by: "types";
1578
+ readonly probe: "strings.kit";
1579
+ }];
1580
+ readonly examples: readonly [{
1581
+ readonly id: "strings.example.lookup";
1582
+ readonly title: "Detect the language and look words up";
1583
+ readonly lang: "ts";
1584
+ readonly code: "// SERVER ONLY: the catalogue holds every language.\nimport { defineStrings } from '@mccaskie-labs/workbench-strings'\nimport enAU from '../strings/strings.en-AU.json'\nimport enPH from '../strings/runtime/strings.en-PH.json'\n\nconst strings = defineStrings({ env: workbenchEnv(env), base: { locale: 'en-AU', strings: enAU }, translations: { 'en-PH': enPH }, locales: ['en-PH', 'en-AU'], defaultLocale: 'en-PH' })\nconst { locale } = strings.detect(request, { account: user?.locale })\nconst title = strings.t(locale, 'ui.cart.count', { count: 3 })";
1585
+ readonly probe: "strings.lookup";
1586
+ }];
1587
+ readonly probes: readonly [{
1588
+ readonly id: "strings.lookup";
1589
+ readonly half: "runtime";
1590
+ readonly expect: "pass";
1591
+ readonly smoke: true;
1592
+ readonly cost_hint_usd: 0;
1593
+ readonly note: "Placeholders and a plural fill in each live locale, and a word missing in a translation comes from the base and is logged";
1594
+ }, {
1595
+ readonly id: "strings.detect";
1596
+ readonly half: "runtime";
1597
+ readonly expect: "pass";
1598
+ readonly cost_hint_usd: 0;
1599
+ readonly note: "Each step of the order wins over the next; a PH visitor whose browser says en-US gets the PH locale; XX falls through to the header";
1600
+ }, {
1601
+ readonly id: "strings.gate";
1602
+ readonly half: "runtime";
1603
+ readonly expect: "pass";
1604
+ readonly cost_hint_usd: 0;
1605
+ readonly note: "An unsigned translation is not inferred (gated: true, base served) but is served from the cookie or prefix";
1606
+ }, {
1607
+ readonly id: "strings.no-reviewer";
1608
+ readonly half: "runtime";
1609
+ readonly expect: "refused_as_expected";
1610
+ readonly refusal: "no_reviewer";
1611
+ readonly cost_hint_usd: 0;
1612
+ readonly note: "defineStrings with env production and a translation with no _review.reviewer";
1613
+ }, {
1614
+ readonly id: "strings.email-missing";
1615
+ readonly half: "runtime";
1616
+ readonly expect: "refused_as_expected";
1617
+ readonly refusal: "missing_string";
1618
+ readonly cost_hint_usd: 0;
1619
+ readonly note: "strings.email for a part no catalogue has";
1620
+ }, {
1621
+ readonly id: "strings.kit";
1622
+ readonly half: "runtime";
1623
+ readonly expect: "pass";
1624
+ readonly cost_hint_usd: 0;
1625
+ readonly note: "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI";
1626
+ }];
1627
+ };
1445
1628
  };
1446
1629
  export type Alias = (typeof capabilities)["ai"]["allowed"]["aliases"][number]["alias"];
1447
1630
  export type GatewayScope = (typeof capabilities)["gateway"]["allowed"]["scopes"][number];
@@ -1,6 +1,6 @@
1
1
  // Generated by `npm run capabilities:build` from capabilities/*.json. Do not edit.
2
2
  // check:capabilities fails in Checks when this file is stale.
3
- export const MANIFEST_VERSION = "c72ad5ff6960";
3
+ export const MANIFEST_VERSION = "d0231144218c";
4
4
  export const capabilities = {
5
5
  "ai": {
6
6
  "$schema": "./schema.json",
@@ -1959,5 +1959,229 @@ export const capabilities = {
1959
1959
  "cost_hint_usd": 0
1960
1960
  }
1961
1961
  ]
1962
+ },
1963
+ "pdf": {
1964
+ "$schema": "./schema.json",
1965
+ "format": 1,
1966
+ "component": "pdf",
1967
+ "title": "Branded PDFs",
1968
+ "summary": "workbench-pdf prints an app's content as a PDF with its brand.json logo, colours and fonts, in the app's own Worker through its Browser Rendering binding (BROWSER). Nothing goes through the Gateway and the app holds no Cloudflare token. The standard layout takes plain-text blocks; html() takes the app's own markup.",
1969
+ "status": "built",
1970
+ "surfaces": {
1971
+ "package": [
1972
+ "createPdfMaker",
1973
+ "WorkbenchPdfError"
1974
+ ]
1975
+ },
1976
+ "limits": [
1977
+ {
1978
+ "id": "pdf.blocks",
1979
+ "value": 2000,
1980
+ "unit": "blocks",
1981
+ "per": "document",
1982
+ "enforced_by": "types"
1983
+ },
1984
+ {
1985
+ "id": "pdf.text",
1986
+ "value": 20000,
1987
+ "unit": "characters",
1988
+ "per": "text",
1989
+ "enforced_by": "types"
1990
+ },
1991
+ {
1992
+ "id": "pdf.logo-size",
1993
+ "value": 1048576,
1994
+ "unit": "bytes",
1995
+ "per": "logo",
1996
+ "enforced_by": "types"
1997
+ },
1998
+ {
1999
+ "id": "pdf.time",
2000
+ "value": 120000,
2001
+ "unit": "ms",
2002
+ "per": "document",
2003
+ "enforced_by": "types"
2004
+ }
2005
+ ],
2006
+ "rules": [
2007
+ {
2008
+ "id": "pdf.brand-file",
2009
+ "kind": "must",
2010
+ "text": "Brand every PDF from the app's brand.json through defineBrand, the same file the account emails use.",
2011
+ "enforced_by": "types",
2012
+ "probe": "pdf.document"
2013
+ },
2014
+ {
2015
+ "id": "pdf.own-binding",
2016
+ "kind": "must_not",
2017
+ "text": "Hold a Cloudflare token for Browser Rendering or send PDFs through the Gateway (hard rules 1 and 2): print through the Worker's own BROWSER binding.",
2018
+ "enforced_by": "review",
2019
+ "probe": "pdf.document"
2020
+ },
2021
+ {
2022
+ "id": "pdf.no-page-network",
2023
+ "kind": "must_not",
2024
+ "text": "Let a printed page run script or load anything but data: URIs, the brand's font hosts and the origins the app lists in allowOrigins.",
2025
+ "enforced_by": "types",
2026
+ "probe": "pdf.html"
2027
+ }
2028
+ ],
2029
+ "examples": [
2030
+ {
2031
+ "id": "pdf.example.document",
2032
+ "title": "Print a branded invoice",
2033
+ "lang": "ts",
2034
+ "code": "import { defineBrand } from '@mccaskie-labs/workbench-config'\nimport { createPdfMaker } from '@mccaskie-labs/workbench-pdf'\nimport brandFile from '../brand.json'\n\nconst pdf = createPdfMaker({ browser: env.BROWSER, brand: defineBrand(brandFile) })\nconst bytes = await pdf.document({ title: 'Invoice 42', blocks: [{ type: 'table', columns: ['Item', 'Amount'], rows: [['Probe run', '₱112.50']] }] })\nreturn new Response(bytes, { headers: { 'content-type': 'application/pdf' } })",
2035
+ "probe": "pdf.document"
2036
+ }
2037
+ ],
2038
+ "probes": [
2039
+ {
2040
+ "id": "pdf.document",
2041
+ "half": "runtime",
2042
+ "expect": "pass",
2043
+ "smoke": true,
2044
+ "cost_hint_usd": 0,
2045
+ "note": "Prints the standard layout from the app's brand.json through the BROWSER binding; checks the bytes are a PDF and the page carried the brand name"
2046
+ },
2047
+ {
2048
+ "id": "pdf.html",
2049
+ "half": "runtime",
2050
+ "expect": "pass",
2051
+ "cost_hint_usd": 0,
2052
+ "note": "Prints the app's own HTML holding an image from a host not in allowOrigins; the PDF is made and the browser reports that image's request as blocked"
2053
+ }
2054
+ ]
2055
+ },
2056
+ "strings": {
2057
+ "$schema": "./schema.json",
2058
+ "format": 1,
2059
+ "component": "strings",
2060
+ "title": "Words on screens and in emails",
2061
+ "summary": "workbench-strings holds the words an app's own screens and emails say: one file per language in the app's repository, looked up on the server with named placeholders and plurals, one fallback chain to the base language, language detection with country before the browser's language, back-translation notes stripped at build, and a review gate so no unreviewed translation is inferred for anyone. The standard's tests ship as a kit.",
2062
+ "status": "built",
2063
+ "surfaces": {
2064
+ "package": [
2065
+ "defineStrings",
2066
+ "resolveLocale",
2067
+ "stripGlosses",
2068
+ "WORKBENCH_LANGUAGES",
2069
+ "WorkbenchStringsError"
2070
+ ]
2071
+ },
2072
+ "rules": [
2073
+ {
2074
+ "id": "strings.server-only",
2075
+ "kind": "must_not",
2076
+ "text": "Import workbench-strings into browser code: the catalogue holds every language. Look words up on the server and pass the finished words to the page; the package's browser export throws.",
2077
+ "enforced_by": "types",
2078
+ "probe": "strings.lookup"
2079
+ },
2080
+ {
2081
+ "id": "strings.no-literals",
2082
+ "kind": "must_not",
2083
+ "text": "Write user-visible words as literals in code, or keep a key nothing renders. Every word is a key in strings/strings.<base>.json, and the test kit's dead-key sweep fails on an unused one.",
2084
+ "enforced_by": "review",
2085
+ "probe": "strings.kit"
2086
+ },
2087
+ {
2088
+ "id": "strings.one-chain",
2089
+ "kind": "must",
2090
+ "text": "Use strings.t and strings.email for every lookup, so screens and emails share one fallback chain: the locale, its parent, then the base. A word found further down the chain is logged.",
2091
+ "enforced_by": "types",
2092
+ "probe": "strings.lookup"
2093
+ },
2094
+ {
2095
+ "id": "strings.country-before-header",
2096
+ "kind": "must",
2097
+ "text": "Detect the language in the order URL prefix, cookie, account, country, Accept-Language, default, with strings.detect. Country comes before the browser's language because phones report English whatever their owner reads.",
2098
+ "enforced_by": "types",
2099
+ "probe": "strings.detect"
2100
+ },
2101
+ {
2102
+ "id": "strings.review-gate",
2103
+ "kind": "must_not",
2104
+ "text": "Serve a translated language to someone who did not choose it until its catalogue has _review.signed_off naming the reviewer and the date; country, header and default resolve to the base until then.",
2105
+ "enforced_by": "types",
2106
+ "probe": "strings.gate"
2107
+ },
2108
+ {
2109
+ "id": "strings.named-reviewer",
2110
+ "kind": "must_not",
2111
+ "text": "Put a translated language live in production without _review.reviewer naming a native-speaker reviewer and their cadence; defineStrings refuses to start.",
2112
+ "enforced_by": "types",
2113
+ "probe": "strings.no-reviewer"
2114
+ },
2115
+ {
2116
+ "id": "strings.email-missing-throws",
2117
+ "kind": "must",
2118
+ "text": "Treat an email word missing in the recipient's language and in the base as an error: strings.email throws missing_string rather than sending a key.",
2119
+ "enforced_by": "types",
2120
+ "probe": "strings.email-missing"
2121
+ },
2122
+ {
2123
+ "id": "strings.glosses-stripped",
2124
+ "kind": "must",
2125
+ "text": "Keep a _gloss back-translation beside every translated string in the authored file, and ship only the generated file from npx workbench-strings build, which strips them; defineStrings refuses a catalogue with glosses.",
2126
+ "enforced_by": "types",
2127
+ "probe": "strings.kit"
2128
+ }
2129
+ ],
2130
+ "examples": [
2131
+ {
2132
+ "id": "strings.example.lookup",
2133
+ "title": "Detect the language and look words up",
2134
+ "lang": "ts",
2135
+ "code": "// SERVER ONLY: the catalogue holds every language.\nimport { defineStrings } from '@mccaskie-labs/workbench-strings'\nimport enAU from '../strings/strings.en-AU.json'\nimport enPH from '../strings/runtime/strings.en-PH.json'\n\nconst strings = defineStrings({ env: workbenchEnv(env), base: { locale: 'en-AU', strings: enAU }, translations: { 'en-PH': enPH }, locales: ['en-PH', 'en-AU'], defaultLocale: 'en-PH' })\nconst { locale } = strings.detect(request, { account: user?.locale })\nconst title = strings.t(locale, 'ui.cart.count', { count: 3 })",
2136
+ "probe": "strings.lookup"
2137
+ }
2138
+ ],
2139
+ "probes": [
2140
+ {
2141
+ "id": "strings.lookup",
2142
+ "half": "runtime",
2143
+ "expect": "pass",
2144
+ "smoke": true,
2145
+ "cost_hint_usd": 0,
2146
+ "note": "Placeholders and a plural fill in each live locale, and a word missing in a translation comes from the base and is logged"
2147
+ },
2148
+ {
2149
+ "id": "strings.detect",
2150
+ "half": "runtime",
2151
+ "expect": "pass",
2152
+ "cost_hint_usd": 0,
2153
+ "note": "Each step of the order wins over the next; a PH visitor whose browser says en-US gets the PH locale; XX falls through to the header"
2154
+ },
2155
+ {
2156
+ "id": "strings.gate",
2157
+ "half": "runtime",
2158
+ "expect": "pass",
2159
+ "cost_hint_usd": 0,
2160
+ "note": "An unsigned translation is not inferred (gated: true, base served) but is served from the cookie or prefix"
2161
+ },
2162
+ {
2163
+ "id": "strings.no-reviewer",
2164
+ "half": "runtime",
2165
+ "expect": "refused_as_expected",
2166
+ "refusal": "no_reviewer",
2167
+ "cost_hint_usd": 0,
2168
+ "note": "defineStrings with env production and a translation with no _review.reviewer"
2169
+ },
2170
+ {
2171
+ "id": "strings.email-missing",
2172
+ "half": "runtime",
2173
+ "expect": "refused_as_expected",
2174
+ "refusal": "missing_string",
2175
+ "cost_hint_usd": 0,
2176
+ "note": "strings.email for a part no catalogue has"
2177
+ },
2178
+ {
2179
+ "id": "strings.kit",
2180
+ "half": "runtime",
2181
+ "expect": "pass",
2182
+ "cost_hint_usd": 0,
2183
+ "note": "The app's own checkStrings run passes in its tests, and npx workbench-strings check passes in CI"
2184
+ }
2185
+ ]
1962
2186
  }
1963
2187
  };
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "access", "r2", "kv", "github"];
2
+ export declare const COMPONENTS: readonly ["gateway", "ai", "images", "image-search", "config", "email", "email-inbound", "email-sending", "database", "workers", "domains", "dns", "analytics", "country", "pdf", "access", "r2", "kv", "github", "strings"];
3
3
  export type Component = (typeof COMPONENTS)[number];
4
4
  export declare const allowedSchemas: {
5
5
  gateway: z.ZodObject<{
@@ -108,7 +108,9 @@ export declare const capabilitySchema: z.ZodObject<{
108
108
  "image-search": "image-search";
109
109
  images: "images";
110
110
  kv: "kv";
111
+ pdf: "pdf";
111
112
  r2: "r2";
113
+ strings: "strings";
112
114
  workers: "workers";
113
115
  }>;
114
116
  title: z.ZodString;
@@ -17,10 +17,12 @@ export const COMPONENTS = [
17
17
  "dns",
18
18
  "analytics",
19
19
  "country",
20
+ "pdf",
20
21
  "access",
21
22
  "r2",
22
23
  "kv",
23
24
  "github",
25
+ "strings",
24
26
  ];
25
27
  const id = z.string().regex(/^[a-z0-9][a-z0-9.-]*$/, "lower case, digits, dots and hyphens");
26
28
  const code = z.string().regex(/^[a-z][a-z0-9_]*$/, "a snake_case error code");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mccaskie-labs/workbench-config",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "Workbench app configuration: WORKBENCH_ENV, typed variables that fail by name, secrets that are never printed, and the capabilities manifest.",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",