@warpgogol/werkstatt-shared 0.8.3 → 0.8.6
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 +135 -0
- package/README.uk.md +135 -0
- package/package.json +7 -1
- package/src/share/entitlement.ts +5 -0
- package/src/share/import-scan.ts +71 -0
- package/src/share/scripts/external-link-qr.ts +196 -0
- package/src/share/scripts/external-links.ts +24 -2
- package/src/share/scripts/index.ts +1 -0
- package/src/share/scripts/orchestrator.ts +17 -4
- package/tsconfig.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @warpgogol/werkstatt-shared
|
|
2
|
+
|
|
3
|
+
[Українська](README.uk.md) | English
|
|
4
|
+
|
|
5
|
+
Stack-agnostic shared infrastructure extracted from `werkstatt-site` (RFC-0868). Owns checks, integration, ontology, passport, share, and surface domains consumed by both the Werkstatt engine and the site plugin.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## What this package does
|
|
10
|
+
|
|
11
|
+
This is a **shared infrastructure library** used by the [Werkstatt](https://www.npmjs.com/package/@warpgogol/werkstatt) engine and the `werkstatt-site` plugin. It provides:
|
|
12
|
+
|
|
13
|
+
- **Checks** — content validators, SEO validators, surface expand/bake logic
|
|
14
|
+
- **Integration** — CRM, funnel, hub, sharding, QStash adapters
|
|
15
|
+
- **Ontology** — catalogs, enums, Sternsystem owner types
|
|
16
|
+
- **Passport** — DHT signing, identity signing, schema validation
|
|
17
|
+
- **Share** — slug generation, semantic extraction, URL canonicalization, route filtering, middleware, access protection
|
|
18
|
+
- **Surface** — surface expand/bake helpers and labels
|
|
19
|
+
|
|
20
|
+
You don't use this package on its own — it is consumed by the engine and site plugin as a dependency.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pnpm add @warpgogol/werkstatt-shared
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
This package is installed automatically when you install `@warpgogol/werkstatt` or `@warpgogol/werkstatt-site`.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## How it fits into the Werkstatt ecosystem
|
|
35
|
+
|
|
36
|
+
| Package | Role |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `@warpgogol/forge` | Governance layer — skills, RFC/ADR workflows, CLI, project scaffolding |
|
|
39
|
+
| `@warpgogol/werkstatt` | Runtime engine — missions, releases, deployment, certification, Bordbuch |
|
|
40
|
+
| `@warpgogol/werkstatt-shared` | **This package** — shared infrastructure (checks, integration, ontology, passport, share) |
|
|
41
|
+
| `@warpgogol/werkstatt-site` | Astro site plugin — consumes this package for checks, integration, ontology, passport, share |
|
|
42
|
+
|
|
43
|
+
The engine and site plugin both import from this package. It MUST NOT import from `werkstatt-site` — enforced by `werkstatt.shared.validate`.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Canonical utilities
|
|
48
|
+
|
|
49
|
+
### Slug generation (RFC-0915, DNA-88)
|
|
50
|
+
|
|
51
|
+
| Export | Purpose |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `slugUrl(text, lang?)` | Locale-aware URL slug (German umlauts, Ukrainian transliteration, default) |
|
|
54
|
+
| `slugId(text)` | Semantic block ID slug |
|
|
55
|
+
| `HeadingSlugger` | Stateful heading anchor deduplication |
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { slugUrl } from "@warpgogol/werkstatt-shared/share/slug";
|
|
59
|
+
|
|
60
|
+
const url = slugUrl("Über uns", "de"); // "ueber-uns"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Semantic extraction (RFC-0901)
|
|
64
|
+
|
|
65
|
+
| Export | Purpose |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `splitSentences(text, locale?)` | Locale-aware sentence boundary detection (`de`, `uk`, `en`) |
|
|
68
|
+
|
|
69
|
+
### Canonical entity URL (RFC-0910)
|
|
70
|
+
|
|
71
|
+
| Export | Purpose |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `canonicalRootUrl(baseUrl)` | Unprefixed root URL for JSON-LD entity identity |
|
|
74
|
+
|
|
75
|
+
### Placeholder route filtering (RFC-0917)
|
|
76
|
+
|
|
77
|
+
| Export | Purpose |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `hasPlaceholderRoutes(routes)` | Detect Astro dynamic route templates (`[slug]`, `[version]`) |
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Architecture
|
|
84
|
+
|
|
85
|
+
| Directory | Purpose |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `src/index.ts` | Main barrel export |
|
|
88
|
+
| `src/checks/` | Content validators, SEO validators, surface expand/bake |
|
|
89
|
+
| `src/integration/` | CRM, funnel, hub, sharding, QStash adapters |
|
|
90
|
+
| `src/ontology/` | Catalogs, enums, Sternsystem owner types |
|
|
91
|
+
| `src/passport/` | DHT signing, identity signing, schema validation |
|
|
92
|
+
| `src/share/` | Slug, semantic, URL canonicalization, routes, middleware, access protection |
|
|
93
|
+
| `src/surface/` | Surface expand/bake helpers and labels |
|
|
94
|
+
| `src/content/` | SystemManifest types and content schema |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Boundary rules
|
|
99
|
+
|
|
100
|
+
- This package MUST NOT import from `@warpgogol/werkstatt-site` — enforced by `werkstatt.shared.validate`.
|
|
101
|
+
- This package MAY import from `@warpgogol/werkstatt` (engine) and external packages.
|
|
102
|
+
- Axiom dependencies (`@syrokomskyi/axiom-*`) are `optionalDependencies` — consumers without axiom installed must use type-only imports or guard runtime access.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Publishing to npm
|
|
107
|
+
|
|
108
|
+
This package is published to the npm registry as `@warpgogol/werkstatt-shared`. Publishing is automated via GitHub Actions CI.
|
|
109
|
+
|
|
110
|
+
### How it works
|
|
111
|
+
|
|
112
|
+
1. The source lives in the [warpgogol/werkstatt](https://github.com/syrokomskyi/werkstatt) monorepo under `packages/werkstatt-shared/`.
|
|
113
|
+
2. [`@warpgogol/repo-extract`](https://github.com/syrokomskyi/repo-extract) extracts the package into the standalone [syrokomskyi/werkstatt-shared](https://github.com/syrokomskyi/werkstatt-shared) repository, flattening it to repo root and stripping workspace dependencies.
|
|
114
|
+
3. The generated GitHub Actions CI workflow runs on every push to `main`: lint → typecheck → build → test → `npm publish --provenance --access public`.
|
|
115
|
+
4. The `NPM_TOKEN` secret must be set in the [repository settings](https://github.com/syrokomskyi/werkstatt-shared/settings/secrets/actions).
|
|
116
|
+
|
|
117
|
+
### Triggering a new release
|
|
118
|
+
|
|
119
|
+
From the werkstatt monorepo root:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
# 1. Bump the version in packages/werkstatt-shared/package.json
|
|
123
|
+
# 2. Run the extraction (extracts + commits + pushes to github.com:syrokomskyi/werkstatt-shared.git)
|
|
124
|
+
pnpm exec repo-extract --config packages/werkstatt-shared/extract.config.yaml --verbose
|
|
125
|
+
|
|
126
|
+
# 3. CI picks up the push and publishes to npm automatically
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
After CI completes, verify the new version on [npmjs.com/package/@warpgogol/werkstatt-shared](https://www.npmjs.com/package/@warpgogol/werkstatt-shared).
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## License
|
|
134
|
+
|
|
135
|
+
Apache-2.0
|
package/README.uk.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# @warpgogol/werkstatt-shared
|
|
2
|
+
|
|
3
|
+
Українська | [English](README.md)
|
|
4
|
+
|
|
5
|
+
Спільна інфраструктура, незалежна від стеку, витягнута з `werkstatt-site` (RFC-0868). Володіє доменами checks, integration, ontology, passport, share та surface, які споживають рушій Werkstatt та site-плагін.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Що робить цей пакет
|
|
10
|
+
|
|
11
|
+
Це **бібліотека спільної інфраструктури**, яку використовують рушій [Werkstatt](https://www.npmjs.com/package/@warpgogol/werkstatt) та плагін `werkstatt-site`. Вона надає:
|
|
12
|
+
|
|
13
|
+
- **Checks** — валідатори контенту, SEO-валідатори, логіка surface expand/bake
|
|
14
|
+
- **Integration** — адаптери CRM, funnel, hub, sharding, QStash
|
|
15
|
+
- **Ontology** — каталоги, enum-и, типи Sternsystem owner
|
|
16
|
+
- **Passport** — DHT-підписування, підписування ідентичності, валідація схем
|
|
17
|
+
- **Share** — генерація slug-ів, семантична екстракція, канонізація URL, фільтрація маршрутів, middleware, захист доступу
|
|
18
|
+
- **Surface** — хелпери surface expand/bake та мітки
|
|
19
|
+
|
|
20
|
+
Цей пакет не використовується самостійно — його споживають рушій та site-плагін як залежність.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Встановлення
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
pnpm add @warpgogol/werkstatt-shared
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Цей пакет встановлюється автоматично при встановленні `@warpgogol/werkstatt` або `@warpgogol/werkstatt-site`.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Як це вписується в екосистему Werkstatt
|
|
35
|
+
|
|
36
|
+
| Пакет | Роль |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `@warpgogol/forge` | Шар управління — навички, RFC/ADR робочі процеси, CLI, скаффолд проєктів |
|
|
39
|
+
| `@warpgogol/werkstatt` | Рушій runtime — місії, релізи, розгортання, сертифікація, Bordbuch |
|
|
40
|
+
| `@warpgogol/werkstatt-shared` | **Цей пакет** — спільна інфраструктура (checks, integration, ontology, passport, share) |
|
|
41
|
+
| `@warpgogol/werkstatt-site` | Astro site-плагін — споживає цей пакет для checks, integration, ontology, passport, share |
|
|
42
|
+
|
|
43
|
+
Рушій та site-плагін імпортують з цього пакунка. Він НЕ ПОВИНЕН імпортувати з `werkstatt-site` — це контролюється `werkstatt.shared.validate`.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Канонічні утиліти
|
|
48
|
+
|
|
49
|
+
### Генерація slug (RFC-0915, DNA-88)
|
|
50
|
+
|
|
51
|
+
| Експорт | Призначення |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `slugUrl(text, lang?)` | Локалізований URL-slug (німецькі умлаути, українська транслітерація, за замовчуванням) |
|
|
54
|
+
| `slugId(text)` | Семантичний slug ID блоку |
|
|
55
|
+
| `HeadingSlugger` | Станковий дедуплікатор якорів заголовків |
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
import { slugUrl } from "@warpgogol/werkstatt-shared/share/slug";
|
|
59
|
+
|
|
60
|
+
const url = slugUrl("Über uns", "de"); // "ueber-uns"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Семантична екстракція (RFC-0901)
|
|
64
|
+
|
|
65
|
+
| Експорт | Призначення |
|
|
66
|
+
| --- | --- |
|
|
67
|
+
| `splitSentences(text, locale?)` | Локалізоване виявлення меж речень (`de`, `uk`, `en`) |
|
|
68
|
+
|
|
69
|
+
### Канонічний URL сутності (RFC-0910)
|
|
70
|
+
|
|
71
|
+
| Експорт | Призначення |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `canonicalRootUrl(baseUrl)` | Непрефіксований кореневий URL для JSON-LD ідентичності сутності |
|
|
74
|
+
|
|
75
|
+
### Фільтрація плейсхолдер-маршрутів (RFC-0917)
|
|
76
|
+
|
|
77
|
+
| Експорт | Призначення |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| `hasPlaceholderRoutes(routes)` | Виявлення Astro динамічних шаблонів маршрутів (`[slug]`, `[version]`) |
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## Архітектура
|
|
84
|
+
|
|
85
|
+
| Директорія | Призначення |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `src/index.ts` | Головний barrel-експорт |
|
|
88
|
+
| `src/checks/` | Валідатори контенту, SEO-валідатори, surface expand/bake |
|
|
89
|
+
| `src/integration/` | Адаптери CRM, funnel, hub, sharding, QStash |
|
|
90
|
+
| `src/ontology/` | Каталоги, enum-и, типи Sternsystem owner |
|
|
91
|
+
| `src/passport/` | DHT-підписування, підписування ідентичності, валідація схем |
|
|
92
|
+
| `src/share/` | Slug, семантика, канонізація URL, маршрути, middleware, захист доступу |
|
|
93
|
+
| `src/surface/` | Хелпери surface expand/bake та мітки |
|
|
94
|
+
| `src/content/` | Типи SystemManifest та схема контенту |
|
|
95
|
+
|
|
96
|
+
---
|
|
97
|
+
|
|
98
|
+
## Межі
|
|
99
|
+
|
|
100
|
+
- Цей пакет НЕ ПОВИНЕН імпортувати з `@warpgogol/werkstatt-site` — контролюється `werkstatt.shared.validate`.
|
|
101
|
+
- Цей пакет МОЖЕ імпортувати з `@warpgogol/werkstatt` (рушій) та зовнішніх пакунків.
|
|
102
|
+
- Axiom-залежності (`@syrokomskyi/axiom-*`) — `optionalDependencies`. Споживачі без axiom повинні використовувати type-only імпорти або охороняти runtime-доступ.
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## Публікація в npm
|
|
107
|
+
|
|
108
|
+
Цей пакет публікується в реєстр npm як `@warpgogol/werkstatt-shared`. Публікація автоматизована через GitHub Actions CI.
|
|
109
|
+
|
|
110
|
+
### Як це працює
|
|
111
|
+
|
|
112
|
+
1. Вихідний код знаходиться в монорепозиторії [warpgogol/werkstatt](https://github.com/syrokomskyi/werkstatt) у `packages/werkstatt-shared/`.
|
|
113
|
+
2. [`@warpgogol/repo-extract`](https://github.com/syrokomskyi/repo-extract) витягує пакет у автономний репозиторій [syrokomskyi/werkstatt-shared](https://github.com/syrokomskyi/werkstatt-shared), вирівнюючи його до кореня репозиторію та видаляючи залежності робочого простору.
|
|
114
|
+
3. Згенерований GitHub Actions CI-воркфлоу запускається при кожному пуші в `main`: lint → typecheck → build → test → `npm publish --provenance --access public`.
|
|
115
|
+
4. Секрет `NPM_TOKEN` має бути встановлений у [налаштуваннях репозиторію](https://github.com/syrokomskyi/werkstatt-shared/settings/secrets/actions).
|
|
116
|
+
|
|
117
|
+
### Запуск нового релізу
|
|
118
|
+
|
|
119
|
+
З кореня монорепозиторію werkstatt:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
# 1. Підняти версію в packages/werkstatt-shared/package.json
|
|
123
|
+
# 2. Запустити екстракцію (витягує + комітить + пушить в github.com:syrokomskyi/werkstatt-shared.git)
|
|
124
|
+
pnpm exec repo-extract --config packages/werkstatt-shared/extract.config.yaml --verbose
|
|
125
|
+
|
|
126
|
+
# 3. CI підхоплює пуш і публікує в npm автоматично
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Після завершення CI перевірте нову версію на [npmjs.com/package/@warpgogol/werkstatt-shared](https://www.npmjs.com/package/@warpgogol/werkstatt-shared).
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## Ліцензія
|
|
134
|
+
|
|
135
|
+
Apache-2.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@warpgogol/werkstatt-shared",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -210,6 +210,10 @@
|
|
|
210
210
|
"types": "./src/share/image-provider.ts",
|
|
211
211
|
"default": "./src/share/image-provider.ts"
|
|
212
212
|
},
|
|
213
|
+
"./share/import-scan": {
|
|
214
|
+
"types": "./src/share/import-scan.ts",
|
|
215
|
+
"default": "./src/share/import-scan.ts"
|
|
216
|
+
},
|
|
213
217
|
"./share/knowledge": {
|
|
214
218
|
"types": "./src/share/knowledge/index.ts",
|
|
215
219
|
"default": "./src/share/knowledge/index.ts"
|
|
@@ -1371,6 +1375,7 @@
|
|
|
1371
1375
|
"hls.js": "^1.6.16",
|
|
1372
1376
|
"lenis": "^1.3.25",
|
|
1373
1377
|
"plyr": "^3.8.4",
|
|
1378
|
+
"qrcode": "^1.5.4",
|
|
1374
1379
|
"gray-matter": "^4.0.3",
|
|
1375
1380
|
"yaml": "^2.9.0",
|
|
1376
1381
|
"zod": "^4.4.3"
|
|
@@ -1387,6 +1392,7 @@
|
|
|
1387
1392
|
"eslint": "^10.8.0",
|
|
1388
1393
|
"fast-check": "^4.9.0",
|
|
1389
1394
|
"gsap": "^3.15.0",
|
|
1395
|
+
"@types/qrcode": "^1.5.5",
|
|
1390
1396
|
"typescript": "^6.0.3",
|
|
1391
1397
|
"typescript-eslint": "8.65.0",
|
|
1392
1398
|
"vitest": "^4.1.10"
|
package/src/share/entitlement.ts
CHANGED
|
@@ -13,6 +13,7 @@ agnostic contract consumed by the build-time resolver, the feature gates, and ru
|
|
|
13
13
|
<item>RFC-0169: initial implementation.</item>
|
|
14
14
|
<item>RFC-0706: add nachweis feature for Nachweisregister commercial module.</item>
|
|
15
15
|
<item>RFC-0741: add multi-currency feature for multi-currency build pipeline.</item>
|
|
16
|
+
<item>RFC-0932: add external-link-qr feature for QR code modal entitlement module.</item>
|
|
16
17
|
</CHANGE_SUMMARY>
|
|
17
18
|
*/
|
|
18
19
|
|
|
@@ -37,6 +38,8 @@ export const ENTITLED_FEATURES = [
|
|
|
37
38
|
"nachweis",
|
|
38
39
|
// RFC-0741: multi-currency entitled feature
|
|
39
40
|
"multi-currency",
|
|
41
|
+
// RFC-0932: external-link QR code modal module
|
|
42
|
+
"external-link-qr",
|
|
40
43
|
] as const;
|
|
41
44
|
|
|
42
45
|
export type EntitledFeature = (typeof ENTITLED_FEATURES)[number];
|
|
@@ -62,6 +65,8 @@ export const STRIPE_FEATURE_LOOKUP_MAP: Record<string, EntitledFeature> = {
|
|
|
62
65
|
feature_nachweis: "nachweis",
|
|
63
66
|
// RFC-0741
|
|
64
67
|
feature_multi_currency: "multi-currency",
|
|
68
|
+
// RFC-0932
|
|
69
|
+
feature_external_link_qr: "external-link-qr",
|
|
65
70
|
};
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>Shared utility for scanning TypeScript source files for import specifiers
|
|
4
|
+
matching a pattern. Used by werkstatt.autonomy.validate, werkstatt.shared.validate,
|
|
5
|
+
and forge.autonomy.validate to avoid code duplication (Fowler: Duplicated Code).</purpose>
|
|
6
|
+
<keywords>scan, import, utility, shared, validate, autonomy</keywords>
|
|
7
|
+
<non-goals>
|
|
8
|
+
<item>Does not define what is forbidden or exempt — callers provide the pattern and filter.</item>
|
|
9
|
+
<item>Does not scan test files — .test.ts and .spec.ts are always excluded.</item>
|
|
10
|
+
</non-goals>
|
|
11
|
+
</MODULE_CONTRACT>
|
|
12
|
+
<CHANGE_SUMMARY>
|
|
13
|
+
<item>RFC-0868: extract shared directory-scanning utility from autonomy-validate and shared-validate.</item>
|
|
14
|
+
<item>RFC-0940: move to @warpgogol/werkstatt-shared so forge can import without depending on @warpgogol/werkstatt.</item>
|
|
15
|
+
</CHANGE_SUMMARY>
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
19
|
+
import { join, relative } from "node:path";
|
|
20
|
+
|
|
21
|
+
export interface ImportViolation {
|
|
22
|
+
file: string;
|
|
23
|
+
specifier: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXCLUDE_DIRS = new Set(["node_modules", "tests", "tests-handoff", "dist", "templates"]);
|
|
27
|
+
const EXCLUDE_SUFFIXES = [".test.ts", ".spec.ts"];
|
|
28
|
+
|
|
29
|
+
const IMPORT_PATTERN =
|
|
30
|
+
/(?:^|\n)\s*(?:import\s+(?:type\s+)?[^;]+?\s+from\s+|require\s*\(\s*)["'`]([^"'`]+)["'`]/g;
|
|
31
|
+
|
|
32
|
+
function shouldExcludeFile(fileName: string): boolean {
|
|
33
|
+
return EXCLUDE_SUFFIXES.some((suffix) => fileName.endsWith(suffix));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function scanDirectoryForImports(
|
|
37
|
+
dir: string,
|
|
38
|
+
workspaceRoot: string,
|
|
39
|
+
specifierFilter: (specifier: string) => boolean,
|
|
40
|
+
): Promise<{ violations: ImportViolation[]; scannedFiles: number }> {
|
|
41
|
+
let scannedFiles = 0;
|
|
42
|
+
const violations: ImportViolation[] = [];
|
|
43
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
44
|
+
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
const fullPath = join(dir, entry.name);
|
|
47
|
+
|
|
48
|
+
if (entry.isDirectory()) {
|
|
49
|
+
if (EXCLUDE_DIRS.has(entry.name)) continue;
|
|
50
|
+
const subResult = await scanDirectoryForImports(fullPath, workspaceRoot, specifierFilter);
|
|
51
|
+
scannedFiles += subResult.scannedFiles;
|
|
52
|
+
violations.push(...subResult.violations);
|
|
53
|
+
} else if (entry.name.endsWith(".ts") && !shouldExcludeFile(entry.name)) {
|
|
54
|
+
scannedFiles++;
|
|
55
|
+
const content = await readFile(fullPath, "utf8").catch(() => "");
|
|
56
|
+
let match: RegExpExecArray | null;
|
|
57
|
+
const pattern = new RegExp(IMPORT_PATTERN.source, "g");
|
|
58
|
+
while ((match = pattern.exec(content)) !== null) {
|
|
59
|
+
const specifier = match[1]!;
|
|
60
|
+
if (specifierFilter(specifier)) {
|
|
61
|
+
violations.push({
|
|
62
|
+
file: relative(workspaceRoot, fullPath),
|
|
63
|
+
specifier,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return { violations, scannedFiles };
|
|
71
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/*
|
|
2
|
+
<MODULE_CONTRACT>
|
|
3
|
+
<purpose>RFC-0932: Client-side QR code modal for external links. Attaches click/keyboard
|
|
4
|
+
handlers to [data-qr-trigger] elements, lazy-loads the qrcode library on first activation,
|
|
5
|
+
and manages the modal lifecycle (open, close, focus trap, body scroll lock).</purpose>
|
|
6
|
+
<non-goals>
|
|
7
|
+
<item>Do not generate QR codes for internal links or non-HTTP links.</item>
|
|
8
|
+
<item>Do not manage entitlement state — the orchestrator only calls initExternalLinkQr() when entitled.</item>
|
|
9
|
+
<item>Do not create the modal DOM — the modal element is rendered by layout-component.astro.</item>
|
|
10
|
+
</non-goals>
|
|
11
|
+
</MODULE_CONTRACT>
|
|
12
|
+
<CHANGE_SUMMARY>
|
|
13
|
+
<item>RFC-0932: initial implementation — QR modal lifecycle, dynamic qrcode import, focus trap, body scroll lock.</item>
|
|
14
|
+
</CHANGE_SUMMARY>
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface ExternalLinkQrOptions {
|
|
18
|
+
/** CSS selector for the modal container element. Default: "[data-external-link-qr-modal]" */
|
|
19
|
+
modalSelector?: string;
|
|
20
|
+
/** QR code error correction level. Default: "M" */
|
|
21
|
+
errorCorrectionLevel?: "L" | "M" | "Q" | "H";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const DEFAULT_MODAL_SELECTOR = "[data-external-link-qr-modal]";
|
|
25
|
+
const DEFAULT_ERROR_CORRECTION = "M" as const;
|
|
26
|
+
|
|
27
|
+
let initialized = false;
|
|
28
|
+
|
|
29
|
+
export function initExternalLinkQr(options?: ExternalLinkQrOptions): void {
|
|
30
|
+
if (initialized) return;
|
|
31
|
+
initialized = true;
|
|
32
|
+
|
|
33
|
+
const modalSelector = options?.modalSelector ?? DEFAULT_MODAL_SELECTOR;
|
|
34
|
+
const errorCorrectionLevel = options?.errorCorrectionLevel ?? DEFAULT_ERROR_CORRECTION;
|
|
35
|
+
|
|
36
|
+
const modalEl = document.querySelector<HTMLElement>(modalSelector);
|
|
37
|
+
if (!modalEl) return;
|
|
38
|
+
const modal: HTMLElement = modalEl;
|
|
39
|
+
|
|
40
|
+
const overlay = modal.querySelector<HTMLElement>("[data-qr-overlay]");
|
|
41
|
+
const canvas = modal.querySelector<HTMLCanvasElement>("[data-qr-canvas]");
|
|
42
|
+
const closeBtnEl = modal.querySelector<HTMLButtonElement>("[data-qr-close]");
|
|
43
|
+
const openLinkBtn = modal.querySelector<HTMLAnchorElement>("[data-qr-open-link]");
|
|
44
|
+
const titleEl = modal.querySelector<HTMLElement>("[data-qr-title]");
|
|
45
|
+
const errorEl = modal.querySelector<HTMLElement>("[data-qr-error]");
|
|
46
|
+
|
|
47
|
+
if (!overlay || !closeBtnEl) return;
|
|
48
|
+
const closeBtn: HTMLButtonElement = closeBtnEl;
|
|
49
|
+
|
|
50
|
+
let lastFocused: HTMLElement | null = null;
|
|
51
|
+
|
|
52
|
+
function openModal(href: string, trigger: HTMLElement): void {
|
|
53
|
+
lastFocused = trigger;
|
|
54
|
+
|
|
55
|
+
if (titleEl) {
|
|
56
|
+
try {
|
|
57
|
+
const url = new URL(href);
|
|
58
|
+
titleEl.textContent = url.hostname.replace(/^www\./, "");
|
|
59
|
+
} catch {
|
|
60
|
+
titleEl.textContent = href;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (openLinkBtn) {
|
|
65
|
+
openLinkBtn.setAttribute("href", href);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (errorEl) {
|
|
69
|
+
errorEl.hidden = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
modal.hidden = false;
|
|
73
|
+
document.body.style.overflow = "hidden";
|
|
74
|
+
|
|
75
|
+
if (canvas) {
|
|
76
|
+
generateQr(href).catch(() => {
|
|
77
|
+
if (errorEl) {
|
|
78
|
+
errorEl.hidden = false;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
closeBtn.focus();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function generateQr(href: string): Promise<void> {
|
|
87
|
+
if (!canvas) return;
|
|
88
|
+
const { toCanvas } = await import("qrcode");
|
|
89
|
+
await toCanvas(canvas, href, {
|
|
90
|
+
width: 256,
|
|
91
|
+
margin: 2,
|
|
92
|
+
errorCorrectionLevel,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function closeModal(): void {
|
|
97
|
+
modal.hidden = true;
|
|
98
|
+
document.body.style.overflow = "";
|
|
99
|
+
|
|
100
|
+
if (canvas) {
|
|
101
|
+
const ctx = canvas.getContext("2d");
|
|
102
|
+
if (ctx) {
|
|
103
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (lastFocused) {
|
|
108
|
+
lastFocused.focus();
|
|
109
|
+
lastFocused = null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isModalOpen(): boolean {
|
|
114
|
+
return !modal.hidden;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function handleTriggerClick(e: Event): void {
|
|
118
|
+
const trigger = e.currentTarget as HTMLElement;
|
|
119
|
+
const anchor = trigger.closest("a");
|
|
120
|
+
if (!anchor) return;
|
|
121
|
+
|
|
122
|
+
const href = anchor.getAttribute("href");
|
|
123
|
+
if (!href) return;
|
|
124
|
+
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
e.stopPropagation();
|
|
127
|
+
openModal(href, trigger);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function handleTriggerKeydown(e: KeyboardEvent): void {
|
|
131
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
e.stopPropagation();
|
|
134
|
+
const trigger = e.currentTarget as HTMLElement;
|
|
135
|
+
const anchor = trigger.closest("a");
|
|
136
|
+
if (!anchor) return;
|
|
137
|
+
const href = anchor.getAttribute("href");
|
|
138
|
+
if (!href) return;
|
|
139
|
+
openModal(href, trigger);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function handleKeydown(e: KeyboardEvent): void {
|
|
143
|
+
if (!isModalOpen()) return;
|
|
144
|
+
|
|
145
|
+
if (e.key === "Escape") {
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
closeModal();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (e.key === "Tab") {
|
|
152
|
+
const focusable = Array.from(
|
|
153
|
+
modal.querySelectorAll<HTMLElement>('button, [href], [tabindex]:not([tabindex="-1"])'),
|
|
154
|
+
).filter((el) => !el.hasAttribute("disabled") && el.offsetParent !== null);
|
|
155
|
+
|
|
156
|
+
if (focusable.length === 0) return;
|
|
157
|
+
|
|
158
|
+
const first = focusable[0];
|
|
159
|
+
const last = focusable[focusable.length - 1];
|
|
160
|
+
|
|
161
|
+
if (e.shiftKey) {
|
|
162
|
+
if (document.activeElement === first) {
|
|
163
|
+
e.preventDefault();
|
|
164
|
+
last.focus();
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
if (document.activeElement === last) {
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
first.focus();
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function attachTriggers(): void {
|
|
176
|
+
const triggers = document.querySelectorAll<HTMLElement>("[data-qr-trigger]");
|
|
177
|
+
for (const trigger of triggers) {
|
|
178
|
+
trigger.addEventListener("click", handleTriggerClick);
|
|
179
|
+
trigger.addEventListener("keydown", handleTriggerKeydown);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
overlay.addEventListener("click", closeModal);
|
|
184
|
+
closeBtn.addEventListener("click", closeModal);
|
|
185
|
+
document.addEventListener("keydown", handleKeydown);
|
|
186
|
+
|
|
187
|
+
if (canvas) {
|
|
188
|
+
canvas.style.cursor = "pointer";
|
|
189
|
+
canvas.addEventListener("click", () => {
|
|
190
|
+
const link = openLinkBtn?.getAttribute("href");
|
|
191
|
+
if (link) window.open(link, "_blank", "noopener,noreferrer");
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
attachTriggers();
|
|
196
|
+
}
|
|
@@ -1,20 +1,29 @@
|
|
|
1
1
|
/*
|
|
2
2
|
<MODULE_CONTRACT>
|
|
3
|
-
<purpose>Applies security attributes to external anchor elements on the page
|
|
3
|
+
<purpose>Applies security attributes to external anchor elements on the page. When the
|
|
4
|
+
external-link-qr entitlement is active, also injects a QR trigger span into each external link.</purpose>
|
|
4
5
|
<non-goals>
|
|
5
6
|
<item>Do not handle internal navigation or routing.</item>
|
|
6
7
|
<item>Do not modify non-HTTP links (mailto:, tel:, etc.).</item>
|
|
8
|
+
<item>Do not generate QR codes — that is the responsibility of external-link-qr.ts.</item>
|
|
7
9
|
</non-goals>
|
|
8
10
|
</MODULE_CONTRACT>
|
|
9
11
|
<CHANGE_SUMMARY>
|
|
10
12
|
<item>RFC-0011 Phase 2: Extracted from layout.astro inline script into src/scripts canonical pattern.</item>
|
|
11
13
|
<item>Migrated to @warpgogol/werkstatt-shared/share/scripts for platform-wide reuse.</item>
|
|
14
|
+
<item>RFC-0932: accept ExternalLinkBehaviorOptions, inject QR trigger span when entitled.</item>
|
|
12
15
|
</CHANGE_SUMMARY>
|
|
13
16
|
*/
|
|
14
17
|
|
|
18
|
+
export interface ExternalLinkBehaviorOptions {
|
|
19
|
+
/** Whether the external-link-qr entitlement is resolved. Default false (fail-closed). */
|
|
20
|
+
externalLinkQrEntitled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
15
23
|
// @ai-invariant: This function must only modify external HTTP/HTTPS links. Never touch mailto:, tel:, or internal links.
|
|
16
|
-
export function applyExternalLinkBehavior(): void {
|
|
24
|
+
export function applyExternalLinkBehavior(options?: ExternalLinkBehaviorOptions): void {
|
|
17
25
|
const anchors = document.querySelectorAll("a[href]");
|
|
26
|
+
const qrEntitled = options?.externalLinkQrEntitled === true;
|
|
18
27
|
|
|
19
28
|
for (const anchor of anchors) {
|
|
20
29
|
const href = anchor.getAttribute("href");
|
|
@@ -41,5 +50,18 @@ export function applyExternalLinkBehavior(): void {
|
|
|
41
50
|
anchor.setAttribute("data-external-link", "1");
|
|
42
51
|
anchor.setAttribute("target", "_blank");
|
|
43
52
|
anchor.setAttribute("rel", "noopener noreferrer");
|
|
53
|
+
|
|
54
|
+
if (qrEntitled && anchor.getAttribute("data-external-link-qr") !== "off") {
|
|
55
|
+
if (!anchor.querySelector("[data-qr-trigger]")) {
|
|
56
|
+
const trigger = document.createElement("span");
|
|
57
|
+
trigger.className = "external-link-qr-trigger";
|
|
58
|
+
trigger.setAttribute("role", "button");
|
|
59
|
+
trigger.setAttribute("tabindex", "0");
|
|
60
|
+
trigger.setAttribute("aria-label", "QR-Code anzeigen");
|
|
61
|
+
trigger.setAttribute("data-qr-trigger", "");
|
|
62
|
+
trigger.textContent = "\u2197";
|
|
63
|
+
anchor.appendChild(trigger);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
44
66
|
}
|
|
45
67
|
}
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// scheduler.ts is loaded dynamically by orchestrator.ts and lordicon.ts
|
|
18
18
|
export * from "./lordicon.ts";
|
|
19
19
|
export * from "./external-links.ts";
|
|
20
|
+
export * from "./external-link-qr.ts";
|
|
20
21
|
export * from "./lenis.ts";
|
|
21
22
|
export * from "./orchestrator.ts";
|
|
22
23
|
// gsap-counter.ts (RFC-0040) is loaded dynamically by orchestrator.ts
|
|
@@ -34,6 +34,8 @@ export interface OrchestrationOptions {
|
|
|
34
34
|
videoPlayers?: boolean;
|
|
35
35
|
/** RFC-0205: opt in to Lenis smooth scroll. Default false — avoids loading the 17 KB bundle when not needed. */
|
|
36
36
|
smoothScroll?: boolean;
|
|
37
|
+
/** RFC-0932: entitlement state for external-link-qr module. Passed from layout. */
|
|
38
|
+
externalLinkQrEntitled?: boolean;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
const has = (selector: string) => document.querySelector(selector) instanceof Element;
|
|
@@ -49,11 +51,15 @@ export async function runStandardLayoutOrchestration(
|
|
|
49
51
|
// 1. External links security (mandatory)
|
|
50
52
|
if (has("a[href]")) {
|
|
51
53
|
if (document.readyState === "loading") {
|
|
52
|
-
document.addEventListener(
|
|
53
|
-
|
|
54
|
-
|
|
54
|
+
document.addEventListener(
|
|
55
|
+
"DOMContentLoaded",
|
|
56
|
+
() => applyExternalLinkBehavior({ externalLinkQrEntitled: options.externalLinkQrEntitled }),
|
|
57
|
+
{
|
|
58
|
+
once: true,
|
|
59
|
+
},
|
|
60
|
+
);
|
|
55
61
|
} else {
|
|
56
|
-
applyExternalLinkBehavior();
|
|
62
|
+
applyExternalLinkBehavior({ externalLinkQrEntitled: options.externalLinkQrEntitled });
|
|
57
63
|
}
|
|
58
64
|
}
|
|
59
65
|
|
|
@@ -139,4 +145,11 @@ export async function runStandardLayoutOrchestration(
|
|
|
139
145
|
await initVideoPlayers({ prefersReducedMotion });
|
|
140
146
|
});
|
|
141
147
|
}
|
|
148
|
+
|
|
149
|
+
// 11. RFC-0932: external-link QR code modal (opt-in via externalLinkQrEntitled: true).
|
|
150
|
+
// The modal element is rendered by layout-component.astro only on entitled sites.
|
|
151
|
+
if (options.externalLinkQrEntitled && has("[data-external-link-qr-modal]")) {
|
|
152
|
+
const { initExternalLinkQr } = await import("./external-link-qr.ts");
|
|
153
|
+
initExternalLinkQr();
|
|
154
|
+
}
|
|
142
155
|
}
|