@uxfront/layer-docs 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +183 -2
- package/app/components/OgImage/OgImageDocs.satori.vue +40 -0
- package/app/components/OgImage/OgImageLanding.satori.vue +41 -0
- package/app/components/app/AppOgDecoration.vue +27 -0
- package/app/components/app/AppOgLogo.vue +19 -0
- package/app/components/content/StorybookEmbed.vue +160 -0
- package/app/pages/[[lang]]/[...slug].vue +10 -0
- package/app/pages/[[lang]]/docs/[section]/[...slug].vue +6 -0
- package/app/utils/storybookEmbed.test.ts +98 -0
- package/app/utils/storybookEmbed.ts +93 -0
- package/modules/config.ts +22 -0
- package/nuxt.config.ts +51 -1
- package/nuxt.schema.ts +15 -0
- package/package.json +20 -2
- package/storybook/index.test.ts +110 -0
- package/storybook/index.ts +362 -0
- package/test/brand-palette.ts +235 -0
- package/test/no-brand-leakage.test.ts +124 -0
- package/utils/accent.ts +80 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The Storybook embed is the layer's most leak-prone surface: it is the only
|
|
8
|
+
* component whose whole job is to address somebody else's deployment. A default
|
|
9
|
+
* host "just for now", or a message name copied out of the repo it came from,
|
|
10
|
+
* breaks no build — it points the *next* consumer's docs at the previous
|
|
11
|
+
* consumer's Storybook, quietly. So it is a test, not a review habit.
|
|
12
|
+
*
|
|
13
|
+
* Three assertions, matching the three shapes the leak takes:
|
|
14
|
+
*
|
|
15
|
+
* 1. The embed's own source names no consumer.
|
|
16
|
+
* 2. Nothing in the package hardcodes a Storybook host.
|
|
17
|
+
* 3. Nothing in the package speaks a branded message namespace.
|
|
18
|
+
*
|
|
19
|
+
* Scopes differ on purpose. (1) is narrow because naming a consumer in a
|
|
20
|
+
* comment elsewhere is often a receipt — "+26.7 KB gzip on <consumer>,
|
|
21
|
+
* UXF-118" is evidence, not branding, and a guard that banned it would be
|
|
22
|
+
* training people to delete their sources. (2) and (3) are package-wide because
|
|
23
|
+
* a hardcoded host or a branded message name is a defect wherever it appears.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
|
|
27
|
+
|
|
28
|
+
/** The embed surface: the files that exist to talk to a consumer's Storybook. */
|
|
29
|
+
const EMBED_SOURCES = [
|
|
30
|
+
"app/components/content/StorybookEmbed.vue",
|
|
31
|
+
"app/utils/storybookEmbed.ts",
|
|
32
|
+
"storybook/index.ts",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Products that extend this layer. A match inside the embed surface means the
|
|
37
|
+
* neutral component learned something about a specific consumer.
|
|
38
|
+
*/
|
|
39
|
+
const CONSUMER_BRANDS = ["styleframe", "inkline"];
|
|
40
|
+
|
|
41
|
+
/** Hostnames that exist only to be read, never fetched. */
|
|
42
|
+
const DOCUMENTATION_HOSTS = ["example.com", "example.org", "localhost", "127.0.0.1"];
|
|
43
|
+
|
|
44
|
+
/** The one namespace the package is allowed to speak. */
|
|
45
|
+
const NEUTRAL_NAMESPACE = "uxfront:docs-embed";
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Any `<namespace>:theme` / `:height` / `:preview-height` string literal — the
|
|
49
|
+
* shape of this contract's message names, whoever coined them.
|
|
50
|
+
*
|
|
51
|
+
* Quoted strings only, not backticked ones: backticks here mark prose in doc
|
|
52
|
+
* comments, and a real message name built as a template literal is
|
|
53
|
+
* parameterized (`${namespace}:theme`) rather than branded.
|
|
54
|
+
*/
|
|
55
|
+
const MESSAGE_NAME_LITERAL = /["']([\w.-]+):(?:theme|height|preview-height)["']/g;
|
|
56
|
+
|
|
57
|
+
const IGNORED_DIRECTORIES = new Set(["node_modules", ".nuxt", ".output", "dist"]);
|
|
58
|
+
const SOURCE_EXTENSIONS = [".ts", ".vue", ".css", ".json"];
|
|
59
|
+
|
|
60
|
+
interface SourceFile {
|
|
61
|
+
path: string;
|
|
62
|
+
source: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readSourceFiles(directory: string, collected: SourceFile[] = []): SourceFile[] {
|
|
66
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
67
|
+
const path = join(directory, entry.name);
|
|
68
|
+
|
|
69
|
+
if (entry.isDirectory()) {
|
|
70
|
+
if (!IGNORED_DIRECTORIES.has(entry.name)) readSourceFiles(path, collected);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!SOURCE_EXTENSIONS.some((extension) => entry.name.endsWith(extension))) continue;
|
|
74
|
+
// Tests quote the literals they exercise — including a legacy namespace,
|
|
75
|
+
// which is the whole point of the compat-window cases.
|
|
76
|
+
if (entry.name.endsWith(".test.ts")) continue;
|
|
77
|
+
|
|
78
|
+
collected.push({ path: relative(PACKAGE_ROOT, path), source: readFileSync(path, "utf8") });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return collected;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe("no brand leakage in the Storybook embed", () => {
|
|
85
|
+
const files = readSourceFiles(PACKAGE_ROOT);
|
|
86
|
+
const embedFiles = files.filter((file) => EMBED_SOURCES.includes(file.path));
|
|
87
|
+
|
|
88
|
+
// Guards the guard: a scan that silently matched nothing passes everything.
|
|
89
|
+
it("finds the embed surface and the rest of the package", () => {
|
|
90
|
+
expect(embedFiles.map((file) => file.path).sort()).toEqual([...EMBED_SOURCES].sort());
|
|
91
|
+
expect(files.length).toBeGreaterThan(50);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it.each(CONSUMER_BRANDS)("names no consumer (%s) in the embed surface", (brand) => {
|
|
95
|
+
const pattern = new RegExp(brand, "i");
|
|
96
|
+
const offenders = embedFiles
|
|
97
|
+
.filter((file) => pattern.test(file.source))
|
|
98
|
+
.map((file) => file.path);
|
|
99
|
+
|
|
100
|
+
expect(offenders).toEqual([]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("hardcodes no Storybook host — where one is deployed is consumer config", () => {
|
|
104
|
+
const offenders = files.flatMap((file) =>
|
|
105
|
+
[...file.source.matchAll(/https?:\/\/[^\s"'`)]+/g)]
|
|
106
|
+
.map((match) => match[0])
|
|
107
|
+
.filter((url) => /storybook/i.test(url))
|
|
108
|
+
.filter((url) => !DOCUMENTATION_HOSTS.some((host) => url.includes(host)))
|
|
109
|
+
.map((url) => `${file.path}: ${url}`),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(offenders).toEqual([]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("speaks no branded message namespace", () => {
|
|
116
|
+
const offenders = files.flatMap((file) =>
|
|
117
|
+
[...file.source.matchAll(MESSAGE_NAME_LITERAL)]
|
|
118
|
+
.filter((match) => match[1] !== NEUTRAL_NAMESPACE)
|
|
119
|
+
.map((match) => `${file.path}: ${match[0]}`),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
expect(offenders).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
});
|
package/utils/accent.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Fallback accent when the brand token cannot be resolved. */
|
|
5
|
+
export const FALLBACK_ACCENT = "#ffffff";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolve the brand accent colour as a literal, build-time string.
|
|
9
|
+
*
|
|
10
|
+
* OG images are rendered by satori, which has no CSS cascade and no custom
|
|
11
|
+
* properties: `var(--ui-primary)` resolves to nothing. A dynamic Tailwind class
|
|
12
|
+
* is worse than useless, because every consumer *shadows* a stock Tailwind
|
|
13
|
+
* scale name with its own value — `--color-teal: hsl(189 53% 41%)` is not
|
|
14
|
+
* Tailwind's teal, so `text-teal-500` would render the wrong brand. The colour
|
|
15
|
+
* therefore has to arrive already resolved.
|
|
16
|
+
*
|
|
17
|
+
* It is read out of the consumer's CSS entry by following the same two hops the
|
|
18
|
+
* browser does, and that `describeBrandPaletteCss` already asserts:
|
|
19
|
+
*
|
|
20
|
+
* `@theme static { --color-teal: hsl(189, 53%, 41%); }` — the literal
|
|
21
|
+
* `:root { --ui-primary: var(--color-teal); }` — the alias
|
|
22
|
+
*
|
|
23
|
+
* Reading the CSS rather than `ui.colors.primary` is deliberate: `app.config.ts`
|
|
24
|
+
* is not merged into `nuxt.options.appConfig` at module-setup time (that object
|
|
25
|
+
* holds only `nuxt.config.ts`'s own `appConfig` key), so the scale name is not
|
|
26
|
+
* available there. The CSS is available, it is the definition rather than a
|
|
27
|
+
* discriminant, and it needs no configuration from the consumer at all.
|
|
28
|
+
*
|
|
29
|
+
* Shade variants are deliberately not followed: they are declared with
|
|
30
|
+
* relative-colour syntax (`hsl(from var(--color-teal) h s 60%)`), which satori
|
|
31
|
+
* cannot parse either.
|
|
32
|
+
*/
|
|
33
|
+
export function resolveBrandAccent(options: {
|
|
34
|
+
/** The consumer's registered CSS entries (`nuxt.options.css`). */
|
|
35
|
+
cssEntries: string[];
|
|
36
|
+
/** Consumer root, used to resolve relative CSS entry paths. */
|
|
37
|
+
rootDir: string;
|
|
38
|
+
}): { accent: string; reason?: string } {
|
|
39
|
+
const { cssEntries, rootDir } = options;
|
|
40
|
+
|
|
41
|
+
for (const entry of cssEntries) {
|
|
42
|
+
const path = isAbsolute(entry) ? entry : join(rootDir, entry.replace(/^\.\//, ""));
|
|
43
|
+
if (!existsSync(path)) {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const accent = readAccent(readFileSync(path, "utf8"));
|
|
47
|
+
if (accent) {
|
|
48
|
+
return { accent };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
accent: FALLBACK_ACCENT,
|
|
54
|
+
reason:
|
|
55
|
+
"could not resolve `--ui-primary` to a literal colour in the registered " +
|
|
56
|
+
`CSS entries (${cssEntries.join(", ") || "none"})`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Follow `--ui-primary` to the literal it aliases, or take it literally. */
|
|
61
|
+
function readAccent(css: string): string | undefined {
|
|
62
|
+
const uiPrimary = /--ui-primary\s*:\s*([^;}]+)/.exec(css)?.[1]?.trim();
|
|
63
|
+
if (!uiPrimary) {
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const alias = /^var\(\s*(--[\w-]+)\s*\)$/.exec(uiPrimary)?.[1];
|
|
68
|
+
if (!alias) {
|
|
69
|
+
// Already a literal — usable unless it is some other custom-property
|
|
70
|
+
// expression satori would render as nothing.
|
|
71
|
+
return uiPrimary.includes("var(") ? undefined : uiPrimary;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const declared = new RegExp(`${escapeRegExp(alias)}\\s*:\\s*([^;}]+)`).exec(css)?.[1]?.trim();
|
|
75
|
+
return declared && !declared.includes("var(") ? declared : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function escapeRegExp(value: string): string {
|
|
79
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
80
|
+
}
|