@fusion.dev/helix-docs 0.0.0-dev
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/LICENSE +21 -0
- package/content/accessibility.mdx +117 -0
- package/content/angular.mdx +60 -0
- package/content/browser-support.mdx +83 -0
- package/content/building-on-the-headless-tier.mdx +143 -0
- package/content/charting.mdx +93 -0
- package/content/coding-agents.mdx +139 -0
- package/content/color-modes.mdx +87 -0
- package/content/color.mdx +92 -0
- package/content/composition.mdx +171 -0
- package/content/elevation-and-material.mdx +93 -0
- package/content/events.mdx +125 -0
- package/content/forms.mdx +170 -0
- package/content/iconography.mdx +80 -0
- package/content/installation.mdx +125 -0
- package/content/intent-reference.mdx +107 -0
- package/content/introduction.mdx +69 -0
- package/content/localization.mdx +95 -0
- package/content/metrics.mdx +399 -0
- package/content/motion.mdx +91 -0
- package/content/other-frameworks.mdx +70 -0
- package/content/overlays.mdx +102 -0
- package/content/overriding-a-component.mdx +160 -0
- package/content/react.mdx +144 -0
- package/content/recipes.mdx +131 -0
- package/content/routing.mdx +208 -0
- package/content/sandbox.mdx +76 -0
- package/content/server-rendering.mdx +103 -0
- package/content/shape-and-borders.mdx +80 -0
- package/content/size-and-density.mdx +86 -0
- package/content/space-and-size.mdx +89 -0
- package/content/state-and-control.mdx +132 -0
- package/content/surfaces-and-separation.mdx +99 -0
- package/content/svelte.mdx +45 -0
- package/content/theme-builder.mdx +66 -0
- package/content/theming.mdx +111 -0
- package/content/tiers.mdx +159 -0
- package/content/tokens.mdx +88 -0
- package/content/typography.mdx +117 -0
- package/content/using-helix-with-an-agent.mdx +231 -0
- package/content/using-the-elements.mdx +125 -0
- package/content/versioning.mdx +72 -0
- package/content/visual-axes.mdx +75 -0
- package/content/vue.mdx +80 -0
- package/content/without-a-bundler.mdx +119 -0
- package/dist/catalog.d.ts +186 -0
- package/dist/catalog.js +296 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +6 -0
- package/dist/naming.d.ts +14 -0
- package/dist/naming.js +28 -0
- package/dist/pages.generated.d.ts +7 -0
- package/dist/pages.generated.js +284 -0
- package/dist/reference.d.ts +105 -0
- package/dist/reference.js +181 -0
- package/dist/registry.d.ts +89 -0
- package/dist/registry.js +70 -0
- package/dist/sections.d.ts +44 -0
- package/dist/sections.js +36 -0
- package/dist/sources.generated.d.ts +6 -0
- package/dist/sources.generated.js +5158 -0
- package/package.json +46 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A page that uses Helix with no build step, and what that costs.
|
|
3
|
+
parent: installation
|
|
4
|
+
section: Getting started
|
|
5
|
+
title: Without a bundler
|
|
6
|
+
trailing: true
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Without a bundler
|
|
10
|
+
|
|
11
|
+
Helix ships standard ES modules and plain CSS files, so a page can use it with
|
|
12
|
+
no build step at all. Two ways, and they suit different things.
|
|
13
|
+
|
|
14
|
+
## Styles alone
|
|
15
|
+
|
|
16
|
+
```html
|
|
17
|
+
<link
|
|
18
|
+
href="/vendor/helix/all.css"
|
|
19
|
+
rel="stylesheet"
|
|
20
|
+
/>
|
|
21
|
+
|
|
22
|
+
<button
|
|
23
|
+
data-button="solid"
|
|
24
|
+
data-button-tone="primary"
|
|
25
|
+
>Save</button>
|
|
26
|
+
<div data-card>
|
|
27
|
+
<h2>A card</h2>
|
|
28
|
+
<p data-prose>Written in your own markup.</p>
|
|
29
|
+
</div>
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`@fusion.dev/helix-styles` has no dependencies and no JavaScript. It carries the token
|
|
33
|
+
layer, the element reset, the type scale on bare headings, and recipes for
|
|
34
|
+
markup this library does not render, so a page can look like Helix without a
|
|
35
|
+
single custom element on it.
|
|
36
|
+
|
|
37
|
+
This is a real way to adopt Helix rather than a fallback, and it is the whole of
|
|
38
|
+
what some pages need. See [Recipes](./recipes) for what the base layer provides.
|
|
39
|
+
|
|
40
|
+
## The elements, through an import map
|
|
41
|
+
|
|
42
|
+
```html
|
|
43
|
+
<script type="importmap">
|
|
44
|
+
{
|
|
45
|
+
"imports": {
|
|
46
|
+
"@fusion.dev/helix": "/vendor/helix/styled/dist/index.js",
|
|
47
|
+
"@fusion.dev/helix-core": "/vendor/helix/core/dist/index.js",
|
|
48
|
+
"@fusion.dev/helix-icons": "/vendor/helix/icons/dist/index.js",
|
|
49
|
+
"lit": "/vendor/lit/index.js"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
</script>
|
|
53
|
+
<script type="module">
|
|
54
|
+
import '@fusion.dev/helix'
|
|
55
|
+
</script>
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The components are built on Lit, so `lit` has to resolve too. That is the part
|
|
59
|
+
that makes an import map necessary rather than optional: the package's own
|
|
60
|
+
imports are bare specifiers, and a browser needs to be told where each one is.
|
|
61
|
+
|
|
62
|
+
**Copy the files, do not point at a CDN.** An import map pointing at someone
|
|
63
|
+
else's server makes your page stop working when theirs does, which is the
|
|
64
|
+
dependency the Sovereignty Constraint exists to refuse. `bun add` and then copy
|
|
65
|
+
`node_modules/@fusion.dev/helix-*/dist` into whatever you serve.
|
|
66
|
+
|
|
67
|
+
## What this costs
|
|
68
|
+
|
|
69
|
+
**Every element ships, whether the page uses one or not.** A bundler is what
|
|
70
|
+
drops the components you never wrote, and without one the browser fetches the
|
|
71
|
+
whole library. Fine for an internal tool or a prototype; not what you want on a
|
|
72
|
+
landing page.
|
|
73
|
+
|
|
74
|
+
You can narrow it by importing subpaths instead:
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import '@fusion.dev/helix/button'
|
|
78
|
+
import '@fusion.dev/helix/dialog'
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Each still pulls its own dependencies, so this is a reduction rather than a
|
|
82
|
+
minimum, but for a page using five components it is a large one.
|
|
83
|
+
|
|
84
|
+
**No TypeScript, so no typings.** A misspelled property is silent here where it
|
|
85
|
+
is a compile error in React, Vue and Svelte, which is worth a note in review.
|
|
86
|
+
Nothing else about writing Helix changes: a declared property is a seed and the
|
|
87
|
+
element applies its own changes. See [State and
|
|
88
|
+
control](./state-and-control).
|
|
89
|
+
|
|
90
|
+
## The stylesheets are the same
|
|
91
|
+
|
|
92
|
+
```html
|
|
93
|
+
<link
|
|
94
|
+
href="/vendor/helix/all.css"
|
|
95
|
+
rel="stylesheet"
|
|
96
|
+
/>
|
|
97
|
+
<link
|
|
98
|
+
href="/vendor/helix/styled/elements.css"
|
|
99
|
+
rel="stylesheet"
|
|
100
|
+
/>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`all.css` carries `primitives.css`, `tokens.css` and `base.css` in the order they
|
|
104
|
+
have to be in, and its `@import`s are relative, so a browser resolves them
|
|
105
|
+
against the stylesheet's own URL with no bundler involved. Copy the whole
|
|
106
|
+
`dist` folder and link the one file. Verified in Chromium: one `<link>` produces
|
|
107
|
+
four requests and both layers resolve.
|
|
108
|
+
|
|
109
|
+
*This page listed `tokens.css` and `base.css` and no `primitives.css` until 24
|
|
110
|
+
Aug 2026, which is the same omission `installation.mdx`, `introduction.mdx` and
|
|
111
|
+
the agent page carried. `tokens.css` refers to a primitive 235 times and imports
|
|
112
|
+
nothing, so a reader who followed this got all 178 semantic tokens declared and
|
|
113
|
+
every one resolving to `rgba(0, 0, 0, 0)`. Linking `all.css` is the fix that
|
|
114
|
+
cannot be got wrong again.*
|
|
115
|
+
|
|
116
|
+
`elements.css` stays its own link, being `@fusion.dev/helix`'s rather than this
|
|
117
|
+
package's, and it matters more here than anywhere: with no bundler the module
|
|
118
|
+
graph arrives over the network, so the interval where the markup exists and the
|
|
119
|
+
definitions do not is longer than it is in a built app.
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The component catalog: manifests and example markup, as one shape a reader
|
|
3
|
+
* can render.
|
|
4
|
+
*
|
|
5
|
+
* **Pure, and it lives here for the same reason `reference()` does.** This is
|
|
6
|
+
* Helix knowledge, and it used to sit in a marketing app: once both an HTML
|
|
7
|
+
* page and a `.md` route need it, leaving it there means one of them imports
|
|
8
|
+
* across a domain boundary to get at it. Reading the manifests and the
|
|
9
|
+
* `.examples/*.html` files off disk is the caller's job, because a content
|
|
10
|
+
* package that touched the filesystem would trace itself into every consumer's
|
|
11
|
+
* server bundle.
|
|
12
|
+
*
|
|
13
|
+
* Two sources and no third: the manifests describe the API, and the example
|
|
14
|
+
* files hold the markup. Nothing about a component is written down here, which
|
|
15
|
+
* is what keeps a site from drifting away from the library it documents.
|
|
16
|
+
*/
|
|
17
|
+
import type { HelixManifest } from '@fusion.dev/helix-cem';
|
|
18
|
+
export interface ApiMember {
|
|
19
|
+
description: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/**
|
|
22
|
+
* Where the platform feature this is built on is specified, if it is one.
|
|
23
|
+
*
|
|
24
|
+
* Optional here and required on `PropertyDoc`, because a property always has
|
|
25
|
+
* the field even when it is empty and a slot or a part is built on nothing.
|
|
26
|
+
* It is on the member rather than only on the property so the Read table can
|
|
27
|
+
* carry a mark: `form` and `labels` are read-only and are the two members in
|
|
28
|
+
* the library that most need one.
|
|
29
|
+
*/
|
|
30
|
+
references?: {
|
|
31
|
+
label: string;
|
|
32
|
+
url: string;
|
|
33
|
+
}[];
|
|
34
|
+
type: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A property, which carries a default where an attribute does not.
|
|
38
|
+
*
|
|
39
|
+
* Separate from `ApiMember` rather than an optional field on it, because a slot
|
|
40
|
+
* and a CSS part have no default and a column of empty cells is a table saying
|
|
41
|
+
* the data is missing rather than that the concept does not apply.
|
|
42
|
+
*/
|
|
43
|
+
export interface PropertyDoc {
|
|
44
|
+
default: string;
|
|
45
|
+
description: string;
|
|
46
|
+
name: string;
|
|
47
|
+
/** Whether it is a getter, so a reader is not invited to set it. */
|
|
48
|
+
readonly: boolean;
|
|
49
|
+
/** Where the platform feature this is built on is specified. */
|
|
50
|
+
references: {
|
|
51
|
+
label: string;
|
|
52
|
+
url: string;
|
|
53
|
+
}[];
|
|
54
|
+
type: string;
|
|
55
|
+
}
|
|
56
|
+
export interface EventDoc {
|
|
57
|
+
description: string;
|
|
58
|
+
name: string;
|
|
59
|
+
reactProp: string;
|
|
60
|
+
type: string;
|
|
61
|
+
}
|
|
62
|
+
export interface Element {
|
|
63
|
+
attributes: ApiMember[];
|
|
64
|
+
cssParts: ApiMember[];
|
|
65
|
+
cssProperties: ApiMember[];
|
|
66
|
+
description: string;
|
|
67
|
+
events: EventDoc[];
|
|
68
|
+
label: string;
|
|
69
|
+
methods: ApiMember[];
|
|
70
|
+
/** Role within the family. `Root` for its primary element. */
|
|
71
|
+
part: string;
|
|
72
|
+
properties: PropertyDoc[];
|
|
73
|
+
slots: ApiMember[];
|
|
74
|
+
summary: string;
|
|
75
|
+
tagName: string;
|
|
76
|
+
tier: 'headless' | 'styled';
|
|
77
|
+
}
|
|
78
|
+
export interface Example {
|
|
79
|
+
/** The example's own stylesheet, or empty where it needs none. */
|
|
80
|
+
css: string;
|
|
81
|
+
/** One line from the example's `@summary` comment, or empty where unwritten. */
|
|
82
|
+
description: string;
|
|
83
|
+
html: string;
|
|
84
|
+
label: string;
|
|
85
|
+
/**
|
|
86
|
+
* The frame layout this example asks for, where it asks for one.
|
|
87
|
+
*
|
|
88
|
+
* Absent on almost every example, which is the design rather than an
|
|
89
|
+
* oversight: the reference picks `centered` and only the exceptions say
|
|
90
|
+
* otherwise, so what is written down is the handful of facts that differ
|
|
91
|
+
* rather than a table of defaults kept by hand. A consumer that has no
|
|
92
|
+
* layouts can ignore it.
|
|
93
|
+
*/
|
|
94
|
+
layout?: string;
|
|
95
|
+
/**
|
|
96
|
+
* The least height the preview pane should give this example, in pixels.
|
|
97
|
+
*
|
|
98
|
+
* Present on the six overlay families and nowhere else. Height is derived
|
|
99
|
+
* from what the frame drew, so a declared one is a fact that can go stale;
|
|
100
|
+
* these are the examples where there is nothing to derive, because what they
|
|
101
|
+
* open is `position: fixed` and excluded from measurement by design.
|
|
102
|
+
*/
|
|
103
|
+
min?: number;
|
|
104
|
+
slug: string;
|
|
105
|
+
/**
|
|
106
|
+
* The same example authored in TSX, which is a file and never a conversion.
|
|
107
|
+
*
|
|
108
|
+
* **Both syntaxes are authored, neither derived.** The sandbox used to run
|
|
109
|
+
* the HTML through `htmlToJsx` when a reader asked for TSX, which is the
|
|
110
|
+
* mistake `build-headless-examples.mjs` made one axis over and this package
|
|
111
|
+
* already rejected once: a derived example is a function of its twin rather
|
|
112
|
+
* than a thing anybody wrote. It also did not work. Every example carrying a
|
|
113
|
+
* `<script>` converted to a JSX fragment with a `<script>` in it, which is a
|
|
114
|
+
* syntax error rather than a bad example, and three separate faults hit the
|
|
115
|
+
* script-free ones as well.
|
|
116
|
+
*
|
|
117
|
+
* `check:examples-paired` holds the two files to each other and
|
|
118
|
+
* `check:visual` holds the two pictures to each other.
|
|
119
|
+
*/
|
|
120
|
+
tsx: string;
|
|
121
|
+
/**
|
|
122
|
+
* Which tier the example shows, read from what it renders.
|
|
123
|
+
*
|
|
124
|
+
* Off the markup rather than off the filename, the same test `check:tokens`
|
|
125
|
+
* uses to know a headless example's stylesheet may write raw values: an
|
|
126
|
+
* example is a headless one when it renders a `helix-headless-` element.
|
|
127
|
+
* When this was written the file was called `headless.html` and the name
|
|
128
|
+
* could have answered, which is why the comment argued against relying on
|
|
129
|
+
* it. Both tiers call theirs `preview.html` now, the package directory
|
|
130
|
+
* carrying the tier, so the name cannot answer and this is the only fact
|
|
131
|
+
* available. The word `base` was the tier's old name and is gone with it.
|
|
132
|
+
*/
|
|
133
|
+
tier: 'headless' | 'styled';
|
|
134
|
+
}
|
|
135
|
+
export interface Family {
|
|
136
|
+
aliases: string[];
|
|
137
|
+
/** What the component is for, from `@category`. The gallery groups on it. */
|
|
138
|
+
category: string;
|
|
139
|
+
documentation: string;
|
|
140
|
+
elements: Element[];
|
|
141
|
+
examples: Example[];
|
|
142
|
+
label: string;
|
|
143
|
+
since: string;
|
|
144
|
+
slug: string;
|
|
145
|
+
/** The family's source directory, relative to the repository root. */
|
|
146
|
+
source: string;
|
|
147
|
+
status: string;
|
|
148
|
+
summary: string;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* One example as it sits on disk, before this decides what to call it.
|
|
152
|
+
*
|
|
153
|
+
* An example is up to two files, `<slug>.html` and an optional `<slug>.css`.
|
|
154
|
+
* The HTML is the list: a stylesheet on its own is not an example, and
|
|
155
|
+
* `check:examples` fails on one whose markup is missing rather than showing a
|
|
156
|
+
* sandbox with a stylesheet and nothing to style.
|
|
157
|
+
*
|
|
158
|
+
* The pair is what the sandbox already shows, `index.html` beside `styles.css`,
|
|
159
|
+
* so the second file is the one the reader is looking at rather than a build
|
|
160
|
+
* step. It is also what replaced the `style` attributes these examples used to
|
|
161
|
+
* carry: an inline style is unreachable from a stylesheet, cannot express a
|
|
162
|
+
* hover or a media query, and teaches a reader to write the one thing the
|
|
163
|
+
* library exists to avoid.
|
|
164
|
+
*/
|
|
165
|
+
export interface ExampleSource {
|
|
166
|
+
css: string;
|
|
167
|
+
/** Family folder the example was found in. */
|
|
168
|
+
family: string;
|
|
169
|
+
html: string;
|
|
170
|
+
/** File name without its extension: `default`, `base`, `cards.base`. */
|
|
171
|
+
slug: string;
|
|
172
|
+
/** The authored TSX beside the HTML, never a conversion of it. */
|
|
173
|
+
tsx: string;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Every family across the given manifests, alphabetical, each with its elements
|
|
177
|
+
* and examples.
|
|
178
|
+
*
|
|
179
|
+
* The family a declaration belongs to comes from where its file is rather than
|
|
180
|
+
* from its tag name, which is the same reasoning `place()` carries for the
|
|
181
|
+
* reference: `helix-button-group` is one family and `helix-tab` is a part
|
|
182
|
+
* of another, and nothing in the name says which.
|
|
183
|
+
*/
|
|
184
|
+
export declare const catalog: (manifests: HelixManifest[], examples?: ExampleSource[]) => Family[];
|
|
185
|
+
/** The family of a given slug, from an already-built catalog. */
|
|
186
|
+
export declare const findInCatalog: (families: Family[], slug: string) => Family | undefined;
|
package/dist/catalog.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { displayName, startCase, tierOf } from './naming.js';
|
|
2
|
+
import { place } from './reference.js';
|
|
3
|
+
/* The headless twin's slug, and the suffix every other one carries. Named
|
|
4
|
+
because the sort and the label both ask, and a tier that has been renamed
|
|
5
|
+
once can be renamed again. */
|
|
6
|
+
/**
|
|
7
|
+
* The canonical example's slug, named once because three places knew it.
|
|
8
|
+
*
|
|
9
|
+
* The tier pairing, the first-position pin and the label all had it written
|
|
10
|
+
* out, and renaming `default` to `preview` broke each of them separately: the
|
|
11
|
+
* pin because the old name sorted early by accident, the pairing because a twin
|
|
12
|
+
* mirrors this one, the label because it reads from the name. A fact three
|
|
13
|
+
* places hold is a fact that drifts, which the comment on the sort below
|
|
14
|
+
* already records happening once when the tier was called `base`.
|
|
15
|
+
*/
|
|
16
|
+
const CANONICAL = 'preview';
|
|
17
|
+
const HEADLESS = 'headless';
|
|
18
|
+
const HEADLESS_SUFFIX = /\.headless$/;
|
|
19
|
+
/**
|
|
20
|
+
* Drops `undefined` from a union.
|
|
21
|
+
*
|
|
22
|
+
* Every reflected property is optional, because the default state of a Helix
|
|
23
|
+
* component is the absent attribute: a reflected default would put an attribute
|
|
24
|
+
* on the host that a React consumer's server markup never had. That makes the
|
|
25
|
+
* declared type `'primary' | 'neutral' | undefined`, and the `undefined` is an
|
|
26
|
+
* implementation detail of how the default is expressed rather than a value
|
|
27
|
+
* anyone sets. The table reads better without it, and the attribute being
|
|
28
|
+
* omissible is already what "attribute" means.
|
|
29
|
+
*/
|
|
30
|
+
const withoutUndefined = (text) => text
|
|
31
|
+
.split('|')
|
|
32
|
+
.map((part) => part.trim())
|
|
33
|
+
.filter((part) => part !== 'undefined')
|
|
34
|
+
.join(' | ');
|
|
35
|
+
/**
|
|
36
|
+
* A method the platform calls, rather than one a consumer does.
|
|
37
|
+
*
|
|
38
|
+
* `formResetCallback` and its siblings are public because the platform has to
|
|
39
|
+
* reach them, and they are 55 of the 152 public methods in the library:
|
|
40
|
+
* documenting them puts a reader's own `focus()` and `show()` in a minority on
|
|
41
|
+
* their own component's page. Matching the suffix rather than listing the names
|
|
42
|
+
* covers the custom-element lifecycle and the form-associated one together, and
|
|
43
|
+
* keeps covering them when the platform adds a third.
|
|
44
|
+
*/
|
|
45
|
+
const PLATFORM_CALLBACK = /Callback$/;
|
|
46
|
+
/** Public only. Private and protected are the component's business. */
|
|
47
|
+
const isPublic = (row) => row.privacy === undefined || row.privacy === 'public';
|
|
48
|
+
/**
|
|
49
|
+
* Properties, with the default each one falls back to.
|
|
50
|
+
*
|
|
51
|
+
* Fields rather than attributes, which is the change this page needed: an
|
|
52
|
+
* attribute carries no default in the manifest, so the old table could say a
|
|
53
|
+
* component took `appearance` and never what it did when you left it alone.
|
|
54
|
+
*/
|
|
55
|
+
const properties = (rows) => (rows ?? [])
|
|
56
|
+
/* Instance fields only. A static is the class's, not an element's, and the
|
|
57
|
+
two that reach here are the platform's rather than ours:
|
|
58
|
+
`shadowRootOptions` is Lit's and `formAssociated` is the flag the browser
|
|
59
|
+
reads to decide a control participates in a form. Thirty-five rows across
|
|
60
|
+
the library, none of them anything a consumer sets on a tag. */
|
|
61
|
+
/* `@internal` needs nothing here: the analyzer drops a member carrying it
|
|
62
|
+
before the manifest is written, which was worth finding out before
|
|
63
|
+
building a plugin and a filter to do the same thing twice. */
|
|
64
|
+
.filter((row) => row.kind === 'field' && isPublic(row) && row.static !== true)
|
|
65
|
+
.map((row) => ({
|
|
66
|
+
/* `undefined` is not a default, it is the absence of one, and the
|
|
67
|
+
analyzer records the initialiser verbatim. Printed, the column reads
|
|
68
|
+
"undefined" for every optional property, which is a table telling a
|
|
69
|
+
reader a word rather than a value. Blank says the same thing and says
|
|
70
|
+
it quietly. */
|
|
71
|
+
default: row.default === 'undefined' ? '' : (row.default ?? ''),
|
|
72
|
+
description: row.description ?? '',
|
|
73
|
+
name: row.name ?? '',
|
|
74
|
+
/* A getter reads as a field in the manifest and assigning to one throws,
|
|
75
|
+
which is the same trap the React generator already skips these for. In
|
|
76
|
+
a table they are indistinguishable from a property a reader may set:
|
|
77
|
+
`AvatarGroup.avatars` sat beside its slot looking like a second way to
|
|
78
|
+
pass the same thing, and there is no second way. A hundred and
|
|
79
|
+
fifty-five of them. */
|
|
80
|
+
readonly: row.readonly === true,
|
|
81
|
+
/* From `@reference` on the property. A row for `command` can say it is
|
|
82
|
+
the Invoker Commands API and point at where that is specified, which a
|
|
83
|
+
description sentence can gesture at and cannot carry. */
|
|
84
|
+
references: row.helix?.references ?? [],
|
|
85
|
+
type: withoutUndefined(row.type?.text ?? ''),
|
|
86
|
+
}));
|
|
87
|
+
/**
|
|
88
|
+
* Methods, typed by the signature a caller writes rather than by a return type
|
|
89
|
+
* alone. `(next: string) => boolean` says what to pass; `boolean` does not.
|
|
90
|
+
*/
|
|
91
|
+
const methods = (rows) => (rows ?? [])
|
|
92
|
+
.filter((row) => row.kind === 'method' && isPublic(row) && !PLATFORM_CALLBACK.test(row.name ?? ''))
|
|
93
|
+
.map((row) => {
|
|
94
|
+
const args = (row.parameters ?? [])
|
|
95
|
+
.map((arg) => `${arg.name ?? ''}: ${arg.type?.text ?? 'unknown'}`)
|
|
96
|
+
.join(', ');
|
|
97
|
+
return {
|
|
98
|
+
description: row.description ?? '',
|
|
99
|
+
name: row.name ?? '',
|
|
100
|
+
type: `(${args}) => ${row.return?.type?.text ?? 'void'}`,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
const members = (rows) => (rows ?? []).map((row) => ({
|
|
104
|
+
description: row.description ?? '',
|
|
105
|
+
name: row.name ?? '',
|
|
106
|
+
type: withoutUndefined(row.type?.text ?? ''),
|
|
107
|
+
}));
|
|
108
|
+
/**
|
|
109
|
+
* An annotation comment: a leading `@` inside an HTML comment, and its line.
|
|
110
|
+
*
|
|
111
|
+
* **Only comments that begin with `@`.** An example is allowed one comment
|
|
112
|
+
* about its own scaffolding, and those are written for the reader:
|
|
113
|
+
* `<!-- Preview frame: the submit is swallowed. -->` exists so nobody copies
|
|
114
|
+
* `onsubmit="event.preventDefault()"` into production. Stripping every comment
|
|
115
|
+
* would take that note with it. The sigil is what separates a line addressed to
|
|
116
|
+
* this documentation system from a line addressed to a person.
|
|
117
|
+
*/
|
|
118
|
+
const ANNOTATION = /^[^\S\n]*<!--\s*@[\s\S]*?-->[^\S\n]*\n?/gm;
|
|
119
|
+
/**
|
|
120
|
+
* The example's markup as a reader should receive it.
|
|
121
|
+
*
|
|
122
|
+
* `@summary` is metadata about the example rather than part of it: the cem
|
|
123
|
+
* plugin lifts it into the manifest, the reference prints it as the
|
|
124
|
+
* description, and it was *also* left sitting on line one of every example, so
|
|
125
|
+
* the copy button handed back a comment describing the thing you had just
|
|
126
|
+
* copied. Ninety-odd examples, each one telling a reader that a Helix component
|
|
127
|
+
* needs a docs annotation above it.
|
|
128
|
+
*
|
|
129
|
+
* Stripped here rather than at the reader, because every consumer of the
|
|
130
|
+
* catalog wants the same thing and a site that trimmed it in its own renderer
|
|
131
|
+
* would leave the sandbox and any other consumer showing it.
|
|
132
|
+
*/
|
|
133
|
+
const withoutAnnotations = (html) => html.replace(ANNOTATION, '').trimStart();
|
|
134
|
+
/**
|
|
135
|
+
* The examples of one family, named and ordered.
|
|
136
|
+
*
|
|
137
|
+
* The prose comes from the manifest and the markup from the caller. The
|
|
138
|
+
* composition plugin reads the same folder and records what each example is;
|
|
139
|
+
* the payload stays on disk because it is large and the manifest is parsed by
|
|
140
|
+
* everything. Keyed by slug so the two halves cannot drift into each other's
|
|
141
|
+
* order.
|
|
142
|
+
*/
|
|
143
|
+
const examplesOf = (slug, declared, sources) => {
|
|
144
|
+
const described = new Map((declared?.examples ?? []).map((entry) => [entry.slug ?? '', entry.description ?? '']));
|
|
145
|
+
/* Keyed on slug the same way the descriptions are, and for the same reason:
|
|
146
|
+
the manifest half and the on-disk half are matched rather than zipped, so
|
|
147
|
+
the two cannot drift into each other's order. */
|
|
148
|
+
const layouts = new Map((declared?.examples ?? []).map((entry) => [entry.slug ?? '', entry.layout ?? '']));
|
|
149
|
+
const minimums = new Map((declared?.examples ?? []).map((entry) => [entry.slug ?? '', entry.min ?? 0]));
|
|
150
|
+
return (sources
|
|
151
|
+
.filter((source) => source.family === slug)
|
|
152
|
+
/* `default` first, then alphabetical, with each headless twin directly
|
|
153
|
+
behind the example it mirrors.
|
|
154
|
+
|
|
155
|
+
By slug, not by filename. This used to compare `compact-counts.html`
|
|
156
|
+
against `compact.html`, where `-` precedes `.` and so the longer name
|
|
157
|
+
sorted first, which is a list ordered by a file extension no reader
|
|
158
|
+
ever sees. The extension is not part of an example's identity:
|
|
159
|
+
`compact` and `compact-counts` are what the tabs say, and `compact`
|
|
160
|
+
comes first.
|
|
161
|
+
|
|
162
|
+
**Pairing on the mirrored name, because sorting on the raw slug only
|
|
163
|
+
ever worked by luck.** The twin used to be called `base`, and `base`
|
|
164
|
+
sorts before every family's other examples, so it landed under
|
|
165
|
+
`default` and looked deliberate. Renaming the tier to `headless` moved
|
|
166
|
+
it to the end of the list and broke two specs that had been reading
|
|
167
|
+
that accident as the rule. What the pairing is for is that the tabs
|
|
168
|
+
show the same list on both tiers, which is what `mirrored` below is
|
|
169
|
+
computing anyway: sort on it and the position is a consequence of the
|
|
170
|
+
relationship rather than of where the tier's name falls in the
|
|
171
|
+
alphabet. */
|
|
172
|
+
.sort((a, b) => {
|
|
173
|
+
const pairOf = (slug) => slug === HEADLESS ? CANONICAL : slug.replace(HEADLESS_SUFFIX, '');
|
|
174
|
+
const twin = (slug) => (slug === HEADLESS || HEADLESS_SUFFIX.test(slug) ? 1 : 0);
|
|
175
|
+
const left = pairOf(a.slug);
|
|
176
|
+
const right = pairOf(b.slug);
|
|
177
|
+
if (left !== right) {
|
|
178
|
+
return left === CANONICAL ? -1 : right === CANONICAL ? 1 : left.localeCompare(right);
|
|
179
|
+
}
|
|
180
|
+
return twin(a.slug) - twin(b.slug);
|
|
181
|
+
})
|
|
182
|
+
.map((source) => {
|
|
183
|
+
/* Which styled example this one is the headless twin of. `headless` is
|
|
184
|
+
the twin of `preview`, and `<name>.headless` of `<name>`. Both names
|
|
185
|
+
have moved: the tier was `base` and the canonical example was
|
|
186
|
+
`default`, and this line is the one place that has to know the pairing
|
|
187
|
+
because a headless example is not named for itself. */
|
|
188
|
+
/* Read once and narrowed here rather than spread from the map
|
|
189
|
+
directly: `get` answers `string | undefined`, and under
|
|
190
|
+
`exactOptionalPropertyTypes` an optional property will not take the
|
|
191
|
+
`undefined`. */
|
|
192
|
+
const layout = layouts.get(source.slug);
|
|
193
|
+
const min = minimums.get(source.slug);
|
|
194
|
+
const mirrored = source.slug === HEADLESS ? CANONICAL : source.slug.replace(HEADLESS_SUFFIX, '');
|
|
195
|
+
return {
|
|
196
|
+
css: source.css,
|
|
197
|
+
description: described.get(source.slug) ?? '',
|
|
198
|
+
html: withoutAnnotations(source.html),
|
|
199
|
+
/* **The label is the file's name, with no special case left.** The
|
|
200
|
+
canonical example was called `default` and read as "Preview", a
|
|
201
|
+
translation that existed only because the name did not say what the
|
|
202
|
+
file was: it is the component's headline, not a named variant sitting
|
|
203
|
+
beside the others. Renamed, `startCase` gives "Preview" on its own.
|
|
204
|
+
|
|
205
|
+
**A headless example takes the name of the one it mirrors**, so the two
|
|
206
|
+
tiers show the same list rather than a list that changes its labels
|
|
207
|
+
when a reader changes a preference. Only one of a pair is ever on
|
|
208
|
+
screen, the tier deciding which, so a differing name would be the only
|
|
209
|
+
thing suggesting they are different examples. */
|
|
210
|
+
label: startCase(mirrored),
|
|
211
|
+
...(layout ? { layout } : {}),
|
|
212
|
+
...(min ? { min } : {}),
|
|
213
|
+
slug: source.slug,
|
|
214
|
+
tier: (source.html.includes('<helix-headless-') ? 'headless' : 'styled'),
|
|
215
|
+
tsx: source.tsx,
|
|
216
|
+
};
|
|
217
|
+
}));
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* Every family across the given manifests, alphabetical, each with its elements
|
|
221
|
+
* and examples.
|
|
222
|
+
*
|
|
223
|
+
* The family a declaration belongs to comes from where its file is rather than
|
|
224
|
+
* from its tag name, which is the same reasoning `place()` carries for the
|
|
225
|
+
* reference: `helix-button-group` is one family and `helix-tab` is a part
|
|
226
|
+
* of another, and nothing in the name says which.
|
|
227
|
+
*/
|
|
228
|
+
export const catalog = (manifests, examples = []) => {
|
|
229
|
+
const byFamily = new Map();
|
|
230
|
+
for (const manifest of manifests) {
|
|
231
|
+
for (const module of manifest.modules ?? []) {
|
|
232
|
+
for (const declaration of module.declarations ?? []) {
|
|
233
|
+
if (!declaration.tagName) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const { family } = place(module.path);
|
|
237
|
+
if (!family) {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
byFamily.set(family, [...(byFamily.get(family) ?? []), declaration]);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return [...byFamily.entries()]
|
|
245
|
+
.map(([slug, entries]) => {
|
|
246
|
+
/* The family's own description comes from its styled root: the element
|
|
247
|
+
whose tag is the family name, rather than a part of it. */
|
|
248
|
+
const root = entries.find((entry) => entry.tagName === `helix-${slug}`) ??
|
|
249
|
+
entries[0];
|
|
250
|
+
return {
|
|
251
|
+
aliases: root.helix?.aliases ?? [],
|
|
252
|
+
category: root.helix?.category ?? '',
|
|
253
|
+
documentation: root.helix?.documentation ?? '',
|
|
254
|
+
elements: entries
|
|
255
|
+
.map((entry) => ({
|
|
256
|
+
attributes: members(entry.attributes),
|
|
257
|
+
cssParts: members(entry.cssParts),
|
|
258
|
+
cssProperties: members(entry.cssProperties),
|
|
259
|
+
description: entry.description ?? '',
|
|
260
|
+
events: (entry.events ?? []).map((event) => ({
|
|
261
|
+
description: event.description ?? '',
|
|
262
|
+
name: event.name ?? '',
|
|
263
|
+
reactProp: event.reactProp ?? `on${event.name ?? ''}`,
|
|
264
|
+
type: event.type?.text ?? '',
|
|
265
|
+
})),
|
|
266
|
+
label: displayName(entry.tagName),
|
|
267
|
+
methods: methods(entry.members),
|
|
268
|
+
/* From the manifest, not by stripping the family name off the
|
|
269
|
+
label. The composition plugin already derives this from the
|
|
270
|
+
folder, and "Avatar Group" minus "Avatar" is a string operation
|
|
271
|
+
that happens to work until a part's name contains the family's. */
|
|
272
|
+
part: entry.helix?.part ?? '',
|
|
273
|
+
properties: properties(entry.members),
|
|
274
|
+
slots: members(entry.slots),
|
|
275
|
+
summary: entry.summary ?? '',
|
|
276
|
+
tagName: entry.tagName,
|
|
277
|
+
tier: tierOf(entry.tagName),
|
|
278
|
+
}))
|
|
279
|
+
/* Styled first, then headless. Alphabetical order would put the
|
|
280
|
+
headless tier on top, which is the one most readers never reach
|
|
281
|
+
for. */
|
|
282
|
+
.sort((a, b) => Number(a.tier === 'headless') - Number(b.tier === 'headless') ||
|
|
283
|
+
a.tagName.localeCompare(b.tagName)),
|
|
284
|
+
examples: examplesOf(slug, root.helix, examples),
|
|
285
|
+
label: startCase(slug),
|
|
286
|
+
since: root.helix?.since ?? '',
|
|
287
|
+
slug,
|
|
288
|
+
source: root.helix?.source ?? '',
|
|
289
|
+
status: root.helix?.status ?? '',
|
|
290
|
+
summary: root.summary ?? '',
|
|
291
|
+
};
|
|
292
|
+
})
|
|
293
|
+
.sort((a, b) => a.label.localeCompare(b.label));
|
|
294
|
+
};
|
|
295
|
+
/** The family of a given slug, from an already-built catalog. */
|
|
296
|
+
export const findInCatalog = (families, slug) => families.find((family) => family.slug === slug);
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type { ApiMember, Element, EventDoc, Example, ExampleSource, Family, PropertyDoc, } from './catalog.js';
|
|
2
|
+
export { catalog, findInCatalog } from './catalog.js';
|
|
3
|
+
export { displayName, startCase, tierOf } from './naming.js';
|
|
4
|
+
export { place, type ReferenceFamily, type ReferenceOptions, reference, referenceFamilies, } from './reference.js';
|
|
5
|
+
export { contentPath, type DocNavPage, type DocNavSection, type DocPage, type DocSection, findPage, navigation, pages, sections, } from './registry.js';
|
|
6
|
+
export { findSection } from './sections.js';
|
|
7
|
+
export { docSources } from './sources.generated.js';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { catalog, findInCatalog } from './catalog.js';
|
|
2
|
+
export { displayName, startCase, tierOf } from './naming.js';
|
|
3
|
+
export { place, reference, referenceFamilies, } from './reference.js';
|
|
4
|
+
export { contentPath, findPage, navigation, pages, sections, } from './registry.js';
|
|
5
|
+
export { findSection } from './sections.js';
|
|
6
|
+
export { docSources } from './sources.generated.js';
|
package/dist/naming.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display names for components.
|
|
3
|
+
*
|
|
4
|
+
* The product is Helix, so every component being called "HelixCard" inside the
|
|
5
|
+
* Helix site is noise: it repeats the one thing the reader already knows on
|
|
6
|
+
* every row of the sidebar. The tag keeps its prefix, because that is a real
|
|
7
|
+
* identifier a consumer types.
|
|
8
|
+
*/
|
|
9
|
+
/** `app-header` becomes `App Header`, and `qr-code` becomes `QR Code`. */
|
|
10
|
+
export declare const startCase: (slug: string) => string;
|
|
11
|
+
/** `helix-app-header` becomes `App Header`, `helix-headless-card` becomes `Card`. */
|
|
12
|
+
export declare const displayName: (tagName: string) => string;
|
|
13
|
+
/** Which tier a tag belongs to, read from the prefix rather than the manifest. */
|
|
14
|
+
export declare const tierOf: (tagName: string) => 'headless' | 'styled';
|
package/dist/naming.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display names for components.
|
|
3
|
+
*
|
|
4
|
+
* The product is Helix, so every component being called "HelixCard" inside the
|
|
5
|
+
* Helix site is noise: it repeats the one thing the reader already knows on
|
|
6
|
+
* every row of the sidebar. The tag keeps its prefix, because that is a real
|
|
7
|
+
* identifier a consumer types.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Words a slug spells in lower case that are read as letters.
|
|
11
|
+
*
|
|
12
|
+
* Capitalising the first letter of `qr` gives `Qr`, which is a word nobody says.
|
|
13
|
+
* A slug is lower case because a URL is, and the initialism it came from is
|
|
14
|
+
* lost by the time this sees it, so it has to be put back by name. Kept small
|
|
15
|
+
* and explicit rather than guessed at from length: `ui`, `id` and `api` are
|
|
16
|
+
* initialisms and `is`, `on` and `to` are not.
|
|
17
|
+
*/
|
|
18
|
+
const INITIALISMS = new Set(['api', 'css', 'html', 'id', 'qr', 'ui', 'url']);
|
|
19
|
+
/** `app-header` becomes `App Header`, and `qr-code` becomes `QR Code`. */
|
|
20
|
+
export const startCase = (slug) => slug
|
|
21
|
+
.split('-')
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((word) => INITIALISMS.has(word) ? word.toUpperCase() : word.charAt(0).toUpperCase() + word.slice(1))
|
|
24
|
+
.join(' ');
|
|
25
|
+
/** `helix-app-header` becomes `App Header`, `helix-headless-card` becomes `Card`. */
|
|
26
|
+
export const displayName = (tagName) => startCase(tagName.replace(/^helix-(?:headless-)?/, ''));
|
|
27
|
+
/** Which tier a tag belongs to, read from the prefix rather than the manifest. */
|
|
28
|
+
export const tierOf = (tagName) => tagName.startsWith('helix-headless-') ? 'headless' : 'styled';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every page this package documents, derived from the frontmatter of each MDX
|
|
3
|
+
* file by `bun run docs`. Do not edit: `bun run check:docs` fails when this
|
|
4
|
+
* file and the content disagree, and the content is the source.
|
|
5
|
+
*/
|
|
6
|
+
import type { DocPage } from './registry.js';
|
|
7
|
+
export declare const docPages: DocPage[];
|