@purview-dev/astroc4 1.0.0-prerelease.5 → 1.0.0-prerelease.7

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.
@@ -1,47 +1,87 @@
1
1
  ---
2
- // LikeC4View.astro
3
- // path: src/components/LikeC4View.astro
4
- // Docs-facing wrapper around the LikeC4 React island. Use this from .mdx pages:
5
- // import LikeC4View from '@purview-dev/astroc4/components/LikeC4View.astro';
6
- // <LikeC4View viewId="index" />
7
- // <LikeC4View viewId="edge" projectId="my-project" height={560} />
2
+ // The project is resolved on the server, so a bad `project` fails the build
3
+ // rather than rendering an error box.
4
+
5
+ import { likec4IdFor, projectNames } from 'virtual:astroc4/projects';
8
6
 
9
7
  import DiagramFrame from './DiagramFrame.astro';
10
8
  import DiagramShell from './DiagramShell.astro';
9
+ import LikeC4InlineView from './LikeC4InlineView';
11
10
  import LikeC4ProjectView from './LikeC4ProjectView';
12
11
  import LoadingPlaceholder from './LoadingPlaceholder.astro';
13
12
  import { getIslandLabels, getTranslator } from './labels';
13
+ import { fileKey, resolveProjectId } from './utils';
14
14
 
15
15
  interface Props {
16
- /** View id inside the selected LikeC4 project. */
17
16
  viewId: string;
18
- /** Optional project id (maps to `likec4:react/<projectId>`). */
17
+ /** A project named in the plugin's `projects` option, or discovered under the site root. */
18
+ project?: string;
19
+ /** @deprecated Use `project`. */
19
20
  projectId?: string;
20
- /** Shell heading. */
21
+ /** A single `.c4` file, relative to the site's `src/` folder. */
22
+ file?: string;
21
23
  title?: string;
22
24
  description?: string;
23
- /** Rendered height in pixels. */
25
+ /** Height in pixels. */
24
26
  height?: number;
25
27
  collapsible?: boolean;
26
28
  defaultOpen?: boolean;
27
29
  }
28
30
 
29
- // Starlight augments `App.Locals` with `t`; a plain Astro page has no such
30
- // property (and an empty `Locals` interface, so naming it would not type-check),
31
- // which is why this is read as `unknown` and `getTranslator` falls back to
32
- // English when it is absent.
31
+ // `t` is Starlight's; outside Starlight `App.Locals` is empty, hence `unknown`.
33
32
  const t = getTranslator((Astro.locals as { t?: unknown }).t);
34
33
  const labels = getIslandLabels(t, 'likec4.view.loading');
35
34
 
36
35
  const {
37
36
  viewId,
37
+ project,
38
38
  projectId,
39
+ file,
39
40
  title = t('likec4.view.title'),
40
41
  description = t('likec4.view.description'),
41
42
  height = 480,
42
43
  collapsible = true,
43
44
  defaultOpen = true,
44
45
  } = Astro.props;
46
+
47
+ let inlineModel: unknown;
48
+ let likec4Id: string | undefined;
49
+
50
+ if (file !== undefined) {
51
+ // Compiled on import by the file-layout plugin.
52
+ const layouts = import.meta.glob<{ dump: unknown; viewIds: string[] }>('/src/**/*.c4', {
53
+ query: '?astroc4-layout',
54
+ import: 'default',
55
+ });
56
+ const key = fileKey(file);
57
+ const load = key ? layouts[key] : undefined;
58
+
59
+ if (!load) {
60
+ throw new Error(
61
+ `[astroc4] <LikeC4View file="${file}"> must name a .c4 file under src/` +
62
+ (key ? `; ${key} does not exist.` : '.'),
63
+ );
64
+ }
65
+
66
+ const layout = await load();
67
+
68
+ if (!layout.viewIds.includes(viewId)) {
69
+ throw new Error(`[astroc4] ${key} has no view "${viewId}". Declared: ${layout.viewIds.join(', ')}.`);
70
+ }
71
+
72
+ inlineModel = layout.dump;
73
+ } else if (projectNames.length > 0) {
74
+ const resolution = resolveProjectId(project ?? projectId, projectNames, labels);
75
+
76
+ if (resolution.error) {
77
+ throw new Error(`[astroc4] <LikeC4View viewId="${viewId}">: ${resolution.error}`);
78
+ }
79
+
80
+ likec4Id = likec4IdFor[resolution.resolvedProjectId!];
81
+ } else {
82
+ // The site took discovery over, so only the island knows the projects.
83
+ likec4Id = project ?? projectId;
84
+ }
45
85
  ---
46
86
 
47
87
  <DiagramShell
@@ -57,33 +97,38 @@ const {
57
97
  </div>
58
98
 
59
99
  <DiagramFrame height={height}>
60
- <LikeC4ProjectView viewId={viewId} projectId={projectId} labels={labels} client:only="react">
61
- {
62
- /* `slot="fallback"` is required: default-slot children of a `client:only`
63
- island are parked in an inert <template> and never painted. */
64
- }
65
- <LoadingPlaceholder slot="fallback" label={labels.loading} />
66
- </LikeC4ProjectView>
100
+ {
101
+ inlineModel !== undefined ? (
102
+ <LikeC4InlineView model={inlineModel} viewId={viewId} labels={labels} client:only="react">
103
+ <LoadingPlaceholder slot="fallback" label={labels.loading} />
104
+ </LikeC4InlineView>
105
+ ) : (
106
+ <LikeC4ProjectView viewId={viewId} projectId={likec4Id} labels={labels} client:only="react">
107
+ {/* Without `slot="fallback"`, Astro parks it in an inert <template>. */}
108
+ <LoadingPlaceholder slot="fallback" label={labels.loading} />
109
+ </LikeC4ProjectView>
110
+ )
111
+ }
67
112
  </DiagramFrame>
68
113
  </DiagramShell>
69
114
 
70
115
  <style>
71
- /* Same token contract as DiagramShell (see the note there). */
116
+ /* See DiagramShell for the token contract. */
72
117
  .likec4-badge {
73
- --likec4-border: var(--sl-color-gray-5, rgba(128, 128, 128, 0.35));
74
- --likec4-surface: var(--sl-color-bg, transparent);
75
- --likec4-text-muted: var(--sl-color-gray-2, rgba(128, 128, 128, 0.95));
118
+ --_likec4-border: var(--likec4-border, var(--sl-color-gray-5, rgba(128, 128, 128, 0.35)));
119
+ --_likec4-surface: var(--likec4-surface, var(--sl-color-bg, transparent));
120
+ --_likec4-text-muted: var(--likec4-text-muted, var(--sl-color-gray-2, rgba(128, 128, 128, 0.95)));
76
121
 
77
122
  display: inline-flex;
78
123
  align-items: center;
79
124
  gap: 0.5rem;
80
125
  margin-bottom: 0.75rem;
81
- border: 1px solid var(--likec4-border);
126
+ border: 1px solid var(--_likec4-border);
82
127
  border-radius: 9999px;
83
- background: var(--likec4-surface);
128
+ background: var(--_likec4-surface);
84
129
  padding: 0.25rem 0.75rem;
85
130
  font-size: 0.75rem;
86
131
  line-height: 1rem;
87
- color: var(--likec4-text-muted);
132
+ color: var(--_likec4-text-muted);
88
133
  }
89
134
  </style>
@@ -1,9 +1,4 @@
1
1
  ---
2
- // LoadingPlaceholder.astro
3
- // path: src/components/LoadingPlaceholder.astro
4
- // Slot fallback rendered server-side while a `client:only` island hydrates.
5
- // Reserves roughly the right amount of space so the page doesn't jump.
6
-
7
2
  interface Props {
8
3
  label: string;
9
4
  }
@@ -22,16 +17,16 @@ const { label } = Astro.props;
22
17
  </div>
23
18
 
24
19
  <style>
25
- /* Same token contract as DiagramShell/DiagramFrame (see the note there). */
20
+ /* See DiagramShell for the token contract. */
26
21
  .likec4-loading {
27
- --likec4-border: var(--sl-color-gray-5, rgba(128, 128, 128, 0.35));
28
- --likec4-surface: var(--sl-color-bg, transparent);
29
- --likec4-text-muted: var(--sl-color-gray-2, rgba(128, 128, 128, 0.95));
30
- --likec4-skeleton: var(--sl-color-gray-6, rgba(128, 128, 128, 0.18));
22
+ --_likec4-border: var(--likec4-border, var(--sl-color-gray-5, rgba(128, 128, 128, 0.35)));
23
+ --_likec4-surface: var(--likec4-surface, var(--sl-color-bg, transparent));
24
+ --_likec4-text-muted: var(--likec4-text-muted, var(--sl-color-gray-2, rgba(128, 128, 128, 0.95)));
25
+ --_likec4-skeleton: var(--likec4-skeleton, var(--sl-color-gray-6, rgba(128, 128, 128, 0.18)));
31
26
 
32
- border: 1px dashed var(--likec4-border);
27
+ border: 1px dashed var(--_likec4-border);
33
28
  border-radius: 0.5rem;
34
- background: var(--likec4-surface);
29
+ background: var(--_likec4-surface);
35
30
  padding: 1.25rem;
36
31
  }
37
32
 
@@ -40,7 +35,7 @@ const { label } = Astro.props;
40
35
  font-size: 0.875rem;
41
36
  line-height: 1.25rem;
42
37
  font-weight: 500;
43
- color: var(--likec4-text-muted);
38
+ color: var(--_likec4-text-muted);
44
39
  }
45
40
 
46
41
  .likec4-loading__lines {
@@ -51,7 +46,7 @@ const { label } = Astro.props;
51
46
  .likec4-loading__line {
52
47
  height: 1rem;
53
48
  border-radius: 0.25rem;
54
- background: var(--likec4-skeleton);
49
+ background: var(--_likec4-skeleton);
55
50
  animation: likec4-pulse 1.6s ease-in-out infinite;
56
51
  }
57
52
 
@@ -1,20 +1,10 @@
1
- // icon-source.ts
2
- // path: src/components/icon-source.ts
3
- // Works out how an element's `icon` value should be rendered.
4
- //
5
- // LikeC4 resolves a *relative* icon path against the .c4 file and stores an
6
- // absolute `file://` URL in the compiled model. That path is baked into the
7
- // client bundle, so the icon 404s for every visitor and the author's local
8
- // directory structure ships with the site.
9
- //
10
- // Files under `public/` are the exception worth supporting: Astro serves them
11
- // verbatim at the site root, so the absolute path can be rewritten back to the
12
- // URL it will actually be served from.
1
+ // Local icons are normally rewritten to `/_astroc4/…` URLs at build time by
2
+ // src/icon-plugin.ts; a `file://` value reaching here is one the build could
3
+ // not attribute to a project.
13
4
 
14
5
  /** Everything after a `/public/` segment, which Astro serves at the site root. */
15
6
  const PUBLIC_SEGMENT = /\/public\/(.+)$/;
16
7
 
17
- /** A path the compiler resolved on the build machine, rather than a pack id. */
18
8
  const LOCAL_PATH = /^(file:\/\/|virtual:\/)/;
19
9
 
20
10
  export type IconSource =
@@ -23,14 +13,8 @@ export type IconSource =
23
13
  | { kind: 'unresolvable'; path: string };
24
14
 
25
15
  /**
26
- * Classify an icon value from the compiled model.
27
- *
28
- * - `pack` — a bundled icon id (`tech:nginx`, `aws:…`, …)
29
- * or anything else the LikeC4 renderer handles itself.
30
- * - `url` — render directly as an `<img src>`.
31
- * - `unresolvable` — a build-machine path with no web equivalent. Rendering
32
- * nothing is better than a broken image, and the caller
33
- * warns so it is not silently wrong.
16
+ * `pack` is anything the LikeC4 renderer handles itself; `unresolvable` is a
17
+ * build-machine path with no web equivalent, which renders nothing (and warns).
34
18
  */
35
19
  export function classifyIcon(icon: string | null | undefined): IconSource {
36
20
  const value = icon?.trim();
@@ -39,7 +23,6 @@ export function classifyIcon(icon: string | null | undefined): IconSource {
39
23
  return { kind: 'pack' };
40
24
  }
41
25
 
42
- // Absolute URLs (and protocol-relative ones) are already web-addressable.
43
26
  if (/^(https?:)?\/\//i.test(value)) {
44
27
  return { kind: 'url', src: value };
45
28
  }
@@ -1,19 +1,13 @@
1
- // labels.ts
2
- // path: src/components/labels.ts
3
- // Resolves the plugin's UI strings for a page. The Astro wrappers translate on
4
- // the server and pass the islands plain strings, because `Astro.locals.t` does
5
- // not exist in the browser.
1
+ // The Astro wrappers translate on the server and pass islands plain strings,
2
+ // because `Astro.locals.t` does not exist in the browser.
6
3
 
7
- // Ships alongside this file: `files` publishes `src/components` *and*
8
- // `src/translations.ts`, so the components stay self-contained source. The
9
- // specifier keeps its `.js` extension because that is what the emitted
10
- // declarations carry and what the site's bundler maps back to this `.ts` file.
11
- import { translations } from '../translations.js';
4
+ // `files` publishes `src/translations.ts` next to `src/components`, so this
5
+ // resolves in an installed package.
6
+ import { translations } from '../translations.ts';
12
7
 
13
8
  export type TranslationKey = keyof (typeof translations)['en'];
14
9
  export type Translate = (key: TranslationKey) => string;
15
10
 
16
- /** Every string an island can show after hydration. */
17
11
  export type IslandLabels = {
18
12
  loading: string;
19
13
  errorTitle: string;
@@ -23,6 +17,8 @@ export type IslandLabels = {
23
17
  noProjects: string;
24
18
  /** Template: `{projectId}`, `{available}`. */
25
19
  unknownProject: string;
20
+ /** Template: `{available}`. */
21
+ projectRequired: string;
26
22
  unresolvedProject: string;
27
23
  /** Template: `{projectId}`. */
28
24
  loadFailed: string;
@@ -30,10 +26,7 @@ export type IslandLabels = {
30
26
 
31
27
  const english: Translate = (key) => translations.en[key];
32
28
 
33
- /**
34
- * Wrap `Astro.locals.t`, falling back to English when it is absent — which it
35
- * is on any page Starlight does not render, such as a plain `src/pages/` route.
36
- */
29
+ /** `t` is absent on any page Starlight does not render, so English is the fallback. */
37
30
  export function getTranslator(t: unknown): Translate {
38
31
  if (typeof t !== 'function') return english;
39
32
 
@@ -53,6 +46,7 @@ export function getIslandLabels(t: Translate, loadingKey: TranslationKey): Islan
53
46
  viewIdRequired: t('likec4.error.viewIdRequired'),
54
47
  noProjects: t('likec4.error.noProjects'),
55
48
  unknownProject: t('likec4.error.unknownProject'),
49
+ projectRequired: t('likec4.error.projectRequired'),
56
50
  unresolvedProject: t('likec4.error.unresolvedProject'),
57
51
  loadFailed: t('likec4.error.loadFailed'),
58
52
  };
@@ -60,7 +54,7 @@ export function getIslandLabels(t: Translate, loadingKey: TranslationKey): Islan
60
54
 
61
55
  export const DEFAULT_LABELS: IslandLabels = getIslandLabels(english, 'likec4.view.loading');
62
56
 
63
- /** Fill `{name}` placeholders. Unknown names are left in place so they show up. */
57
+ /** Unknown placeholders are left in place so they show up. */
64
58
  export function format(template: string, values: Record<string, string>): string {
65
59
  return template.replace(/\{(\w+)\}/g, (match, name: string) => values[name] ?? match);
66
60
  }
@@ -0,0 +1,37 @@
1
+ // Mounts the islands into the placeholders src/fences.ts writes for .md pages.
2
+
3
+ import { likec4IdFor, projectNames } from 'virtual:astroc4/projects';
4
+ import { createRoot } from 'react-dom/client';
5
+
6
+ import { DEFAULT_LABELS } from './labels';
7
+ import LikeC4InlineView from './LikeC4InlineView';
8
+ import LikeC4ProjectView from './LikeC4ProjectView';
9
+ import { resolveProjectId } from './utils';
10
+
11
+ const SELECTOR = '[data-astroc4-fence]:not([data-astroc4-mounted])';
12
+
13
+ export function mountFences(root: ParentNode = document): void {
14
+ for (const placeholder of root.querySelectorAll<HTMLElement>(SELECTOR)) {
15
+ placeholder.dataset.astroc4Mounted = '';
16
+
17
+ const frame = placeholder.querySelector<HTMLElement>('.likec4-frame') ?? placeholder;
18
+ const { viewId = '', model, project } = placeholder.dataset;
19
+
20
+ createRoot(frame).render(
21
+ model !== undefined ? (
22
+ <LikeC4InlineView model={JSON.parse(model)} viewId={viewId} labels={DEFAULT_LABELS} />
23
+ ) : (
24
+ <LikeC4ProjectView viewId={viewId} projectId={likec4Id(project)} labels={DEFAULT_LABELS} />
25
+ ),
26
+ );
27
+ }
28
+ }
29
+
30
+ /** A page's project name as LikeC4's id; anything unknown is left for the island to report. */
31
+ function likec4Id(project: string | undefined): string | undefined {
32
+ if (projectNames.length === 0) return project;
33
+
34
+ const { resolvedProjectId } = resolveProjectId(project, projectNames, DEFAULT_LABELS);
35
+
36
+ return resolvedProjectId ? likec4IdFor[resolvedProjectId] : project;
37
+ }
@@ -1,16 +1,7 @@
1
- // render-icon.tsx
2
- // path: src/components/render-icon.tsx
3
- // Shared icon renderer for both the project-model and inline-fence islands.
4
-
5
1
  import type { ElementIconRenderer } from 'likec4:icons';
6
2
 
7
3
  import { classifyIcon } from './icon-source';
8
4
 
9
- /**
10
- * Wrap the bundled-icon renderer so elements can also use an image URL, or a
11
- * local file kept under `public/`. Anything else local has no web equivalent —
12
- * warn rather than render a broken image.
13
- */
14
5
  export function withImageIcons(packRenderer: ElementIconRenderer): ElementIconRenderer {
15
6
  return function IconRenderer(props) {
16
7
  const source = classifyIcon(props.node.icon);
@@ -1,13 +1,8 @@
1
- // utils.ts
2
- // path: src/components/utils.ts
3
- // Pure resolution/validation helpers for <LikeC4View>. Separated from the React
4
- // component so the error-message behaviour can be tested without a DOM.
5
-
6
1
  import { DEFAULT_LABELS, format, type IslandLabels } from './labels';
7
2
 
8
3
  type ErrorLabels = Pick<
9
4
  IslandLabels,
10
- 'viewIdRequired' | 'noProjects' | 'unknownProject' | 'loadFailed'
5
+ 'viewIdRequired' | 'noProjects' | 'unknownProject' | 'projectRequired' | 'loadFailed'
11
6
  >;
12
7
 
13
8
  export function getAvailableProjectIds(
@@ -38,7 +33,18 @@ export function resolveProjectId(
38
33
  };
39
34
  }
40
35
 
41
- const resolvedProjectId = projectId?.trim() || availableProjectIds[0]!;
36
+ const requested = projectId?.trim();
37
+
38
+ // With several projects, "the first one found" depends on folder order —
39
+ // a reorder would silently change the diagram — so the page has to say.
40
+ if (!requested && availableProjectIds.length > 1) {
41
+ return {
42
+ resolvedProjectId: null,
43
+ error: format(labels.projectRequired, { available: availableProjectIds.join(', ') }),
44
+ };
45
+ }
46
+
47
+ const resolvedProjectId = requested || availableProjectIds[0]!;
42
48
 
43
49
  if (!availableProjectIds.includes(resolvedProjectId)) {
44
50
  return {
@@ -56,6 +62,33 @@ export function resolveProjectId(
56
62
  };
57
63
  }
58
64
 
65
+ /**
66
+ * The `import.meta.glob` key for `<LikeC4View file="…">`, or `null`. Only files
67
+ * under `src/` can match the glob, so a path that climbs out loads nothing.
68
+ */
69
+ export function fileKey(file: string): string | null {
70
+ // Absolute paths (`/etc/x.c4`, `C:/x.c4`, `\\server\x.c4`) are never relative to src/.
71
+ if (/^[\\/]/.test(file) || /^[a-z]:/i.test(file)) return null;
72
+
73
+ const segments: string[] = [];
74
+
75
+ for (const segment of file.split(/[\\/]+/)) {
76
+ if (segment === '' || segment === '.') continue;
77
+
78
+ if (segment === '..') {
79
+ if (segments.length === 0) return null;
80
+ segments.pop();
81
+ continue;
82
+ }
83
+
84
+ segments.push(segment);
85
+ }
86
+
87
+ const path = segments.join('/');
88
+
89
+ return path.toLowerCase().endsWith('.c4') ? `/src/${path}` : null;
90
+ }
91
+
59
92
  export function toLoadErrorMessage(
60
93
  err: unknown,
61
94
  resolvedProjectId: string,
@@ -1,13 +1,5 @@
1
- // translations.mjs
2
- // path: src/translations.mjs
3
- // UI strings for the diagram components, injected into Starlight's i18n system
4
- // by the plugin's `i18n:setup` hook. A site overrides or adds a language the
5
- // same way it does for Starlight's own strings — a `src/content/i18n/<lang>.json`
6
- // containing any of these keys.
7
- //
8
- // Placeholders are `{name}`, not i18next's `{{name}}`: some values (the list of
9
- // available projects, say) are only known in the browser, so the template has
10
- // to reach the island unformatted. See `format()` in src/components/labels.ts.
1
+ // Placeholders are `{name}`, not i18next's `{{name}}`: some values are only
2
+ // known in the browser, so the template has to reach the island unformatted.
11
3
 
12
4
  export const translations = {
13
5
  en: {
@@ -25,6 +17,7 @@ export const translations = {
25
17
  'likec4.error.viewIdRequired': 'A LikeC4 viewId is required to render the diagram.',
26
18
  'likec4.error.noProjects': 'No LikeC4 projects are available in this workspace.',
27
19
  'likec4.error.unknownProject': 'Unknown LikeC4 projectId "{projectId}". Available projects: {available}.',
20
+ 'likec4.error.projectRequired': 'Several LikeC4 projects exist ({available}); pass project="…".',
28
21
  'likec4.error.unresolvedProject': 'Unable to resolve a LikeC4 project.',
29
22
  'likec4.error.loadFailed': 'Unable to load LikeC4 project "{projectId}".',
30
23
  },