@purview-dev/astroc4 0.0.0-stage → 1.0.0-prerelease.2
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.md +9 -0
- package/README.md +179 -2
- package/package.json +96 -4
- package/src/components/DiagramFrame.astro +72 -0
- package/src/components/DiagramShell.astro +121 -0
- package/src/components/ErrorBoundary.tsx +52 -0
- package/src/components/LikeC4Fence.astro +62 -0
- package/src/components/LikeC4InlineView.tsx +67 -0
- package/src/components/LikeC4ProjectView.tsx +153 -0
- package/src/components/LikeC4View.astro +80 -0
- package/src/components/LoadingPlaceholder.astro +67 -0
- package/src/components/icon-source.ts +68 -0
- package/src/components/labels.ts +62 -0
- package/src/components/render-icon.tsx +41 -0
- package/src/components/utils.ts +69 -0
- package/src/expressive-code.d.mts +28 -0
- package/src/expressive-code.mjs +46 -0
- package/src/fences.mjs +288 -0
- package/src/grammar/langium.mjs +559 -0
- package/src/grammar/like-c4.langium +1357 -0
- package/src/grammar/likec4.tmLanguage.json +327 -0
- package/src/grammar/source.mjs +410 -0
- package/src/grammar/textmate.mjs +439 -0
- package/src/index.d.mts +64 -0
- package/src/index.mjs +228 -0
- package/src/translations.mjs +33 -0
- package/src/types.d.ts +12 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// LikeC4ProjectView.tsx
|
|
2
|
+
// path: src/components/LikeC4ProjectView.tsx
|
|
3
|
+
// React island that loads a LikeC4 project model (via the `likec4:*` virtual
|
|
4
|
+
// modules provided by LikeC4VitePlugin) and renders one view from it.
|
|
5
|
+
// Browser-only: mount it with `client:only="react"`.
|
|
6
|
+
|
|
7
|
+
import { type ElementIconRenderer, getProjectIcons } from 'likec4:icons';
|
|
8
|
+
import { loadModel } from 'likec4:model';
|
|
9
|
+
import { projects } from 'likec4:projects';
|
|
10
|
+
import type { aux, UnknownLayouted } from 'likec4/model';
|
|
11
|
+
import { LikeC4View as GenericLikeC4View, LikeC4ModelProvider } from 'likec4/react';
|
|
12
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
13
|
+
|
|
14
|
+
import ErrorBoundary from './ErrorBoundary';
|
|
15
|
+
import { DEFAULT_LABELS, type IslandLabels } from './labels';
|
|
16
|
+
import { withImageIcons } from './render-icon';
|
|
17
|
+
import {
|
|
18
|
+
getAvailableProjectIds,
|
|
19
|
+
resolveProjectId,
|
|
20
|
+
toLoadErrorMessage,
|
|
21
|
+
validateViewId,
|
|
22
|
+
} from './utils';
|
|
23
|
+
|
|
24
|
+
type Props = {
|
|
25
|
+
viewId: string;
|
|
26
|
+
projectId?: string;
|
|
27
|
+
/** Translated on the server; defaults to English. */
|
|
28
|
+
labels?: IslandLabels;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type LoadedProjectModel = Awaited<ReturnType<typeof loadModel>>;
|
|
32
|
+
|
|
33
|
+
type LoadedLikeC4ViewProps = {
|
|
34
|
+
loadedProjectModel: LoadedProjectModel;
|
|
35
|
+
viewId: string;
|
|
36
|
+
iconRenderer?: ElementIconRenderer;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
function LoadedLikeC4View({ loadedProjectModel, viewId, iconRenderer }: LoadedLikeC4ViewProps) {
|
|
40
|
+
const model = loadedProjectModel.useLikeC4Model();
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<LikeC4ModelProvider likec4model={model}>
|
|
44
|
+
<GenericLikeC4View
|
|
45
|
+
viewId={viewId as aux.ViewId<UnknownLayouted>}
|
|
46
|
+
controls={false}
|
|
47
|
+
renderIcon={iconRenderer}
|
|
48
|
+
browser={{
|
|
49
|
+
enableFocusMode: false,
|
|
50
|
+
enableSearch: false,
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
</LikeC4ModelProvider>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function LikeC4ProjectViewContent({ viewId, projectId, labels }: Props & { labels: IslandLabels }) {
|
|
58
|
+
const [loadedProjectModel, setLoadedProjectModel] = useState<LoadedProjectModel | null>(null);
|
|
59
|
+
const [error, setError] = useState<string | null>(null);
|
|
60
|
+
// Guards against an older, slower load resolving after a newer one.
|
|
61
|
+
const loadRequestIdRef = useRef(0);
|
|
62
|
+
|
|
63
|
+
const availableProjectIds = useMemo(() => getAvailableProjectIds(projects), []);
|
|
64
|
+
const viewIdError = useMemo(() => validateViewId(viewId, labels), [viewId, labels]);
|
|
65
|
+
|
|
66
|
+
const projectResolution = useMemo(
|
|
67
|
+
() => resolveProjectId(projectId, availableProjectIds, labels),
|
|
68
|
+
[projectId, availableProjectIds, labels],
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const resolvedProjectId = projectResolution.resolvedProjectId;
|
|
72
|
+
|
|
73
|
+
const iconRenderer = useMemo(() => {
|
|
74
|
+
if (!resolvedProjectId) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return withImageIcons(getProjectIcons(resolvedProjectId));
|
|
79
|
+
}, [resolvedProjectId]);
|
|
80
|
+
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
let isActive = true;
|
|
83
|
+
const currentRequestId = ++loadRequestIdRef.current;
|
|
84
|
+
|
|
85
|
+
if (viewIdError) {
|
|
86
|
+
setError(viewIdError);
|
|
87
|
+
setLoadedProjectModel(null);
|
|
88
|
+
return () => {
|
|
89
|
+
isActive = false;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (projectResolution.error || !resolvedProjectId) {
|
|
94
|
+
setError(projectResolution.error ?? labels.unresolvedProject);
|
|
95
|
+
setLoadedProjectModel(null);
|
|
96
|
+
return () => {
|
|
97
|
+
isActive = false;
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setError(null);
|
|
102
|
+
setLoadedProjectModel(null);
|
|
103
|
+
|
|
104
|
+
loadModel(resolvedProjectId)
|
|
105
|
+
.then((projectModel) => {
|
|
106
|
+
if (!isActive || currentRequestId !== loadRequestIdRef.current) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
setLoadedProjectModel(projectModel);
|
|
111
|
+
})
|
|
112
|
+
.catch((err: unknown) => {
|
|
113
|
+
if (!isActive || currentRequestId !== loadRequestIdRef.current) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
setError(toLoadErrorMessage(err, resolvedProjectId, labels));
|
|
118
|
+
setLoadedProjectModel(null);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
isActive = false;
|
|
123
|
+
};
|
|
124
|
+
}, [projectResolution.error, resolvedProjectId, viewIdError, labels]);
|
|
125
|
+
|
|
126
|
+
if (error) {
|
|
127
|
+
return (
|
|
128
|
+
<p role="alert" aria-live="polite">
|
|
129
|
+
{error}
|
|
130
|
+
</p>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!loadedProjectModel) {
|
|
135
|
+
return <p aria-live="polite">{labels.loading}</p>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<LoadedLikeC4View
|
|
140
|
+
loadedProjectModel={loadedProjectModel}
|
|
141
|
+
viewId={viewId}
|
|
142
|
+
iconRenderer={iconRenderer}
|
|
143
|
+
/>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export default function LikeC4ProjectView({ labels = DEFAULT_LABELS, ...props }: Props) {
|
|
148
|
+
return (
|
|
149
|
+
<ErrorBoundary labels={labels}>
|
|
150
|
+
<LikeC4ProjectViewContent {...props} labels={labels} />
|
|
151
|
+
</ErrorBoundary>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
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} />
|
|
8
|
+
|
|
9
|
+
import DiagramFrame from './DiagramFrame.astro';
|
|
10
|
+
import DiagramShell from './DiagramShell.astro';
|
|
11
|
+
import LikeC4ProjectView from './LikeC4ProjectView';
|
|
12
|
+
import LoadingPlaceholder from './LoadingPlaceholder.astro';
|
|
13
|
+
import { getIslandLabels, getTranslator } from './labels';
|
|
14
|
+
|
|
15
|
+
interface Props {
|
|
16
|
+
/** View id inside the selected LikeC4 project. */
|
|
17
|
+
viewId: string;
|
|
18
|
+
/** Optional project id (maps to `likec4:react/<projectId>`). */
|
|
19
|
+
projectId?: string;
|
|
20
|
+
/** Shell heading. */
|
|
21
|
+
title?: string;
|
|
22
|
+
description?: string;
|
|
23
|
+
/** Rendered height in pixels. */
|
|
24
|
+
height?: number;
|
|
25
|
+
collapsible?: boolean;
|
|
26
|
+
defaultOpen?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const t = getTranslator(Astro.locals.t);
|
|
30
|
+
const labels = getIslandLabels(t, 'likec4.view.loading');
|
|
31
|
+
|
|
32
|
+
const {
|
|
33
|
+
viewId,
|
|
34
|
+
projectId,
|
|
35
|
+
title = t('likec4.view.title'),
|
|
36
|
+
description = t('likec4.view.description'),
|
|
37
|
+
height = 480,
|
|
38
|
+
collapsible = true,
|
|
39
|
+
defaultOpen = true,
|
|
40
|
+
} = Astro.props;
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
<DiagramShell
|
|
44
|
+
title={title}
|
|
45
|
+
description={description}
|
|
46
|
+
hint={t('likec4.view.hint')}
|
|
47
|
+
collapsible={collapsible}
|
|
48
|
+
defaultOpen={defaultOpen}
|
|
49
|
+
>
|
|
50
|
+
<div class="likec4-badge">
|
|
51
|
+
<span aria-hidden="true">🖱️</span>
|
|
52
|
+
<span>{t('likec4.view.badge')}</span>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<DiagramFrame height={height}>
|
|
56
|
+
<LikeC4ProjectView viewId={viewId} projectId={projectId} labels={labels} client:only="react">
|
|
57
|
+
{
|
|
58
|
+
/* `slot="fallback"` is required: default-slot children of a `client:only`
|
|
59
|
+
island are parked in an inert <template> and never painted. */
|
|
60
|
+
}
|
|
61
|
+
<LoadingPlaceholder slot="fallback" label={labels.loading} />
|
|
62
|
+
</LikeC4ProjectView>
|
|
63
|
+
</DiagramFrame>
|
|
64
|
+
</DiagramShell>
|
|
65
|
+
|
|
66
|
+
<style>
|
|
67
|
+
.likec4-badge {
|
|
68
|
+
display: inline-flex;
|
|
69
|
+
align-items: center;
|
|
70
|
+
gap: 0.5rem;
|
|
71
|
+
margin-bottom: 0.75rem;
|
|
72
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
73
|
+
border-radius: 9999px;
|
|
74
|
+
background: var(--sl-color-bg);
|
|
75
|
+
padding: 0.25rem 0.75rem;
|
|
76
|
+
font-size: 0.75rem;
|
|
77
|
+
line-height: 1rem;
|
|
78
|
+
color: var(--sl-color-gray-2);
|
|
79
|
+
}
|
|
80
|
+
</style>
|
|
@@ -0,0 +1,67 @@
|
|
|
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
|
+
interface Props {
|
|
8
|
+
label: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { label } = Astro.props;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<div class="likec4-loading" role="status" aria-live="polite" aria-busy="true">
|
|
15
|
+
<div class="likec4-loading__label">{label}</div>
|
|
16
|
+
|
|
17
|
+
<div class="likec4-loading__lines" aria-hidden="true">
|
|
18
|
+
<div class="likec4-loading__line" style="width: 75%"></div>
|
|
19
|
+
<div class="likec4-loading__line" style="width: 83.333%"></div>
|
|
20
|
+
<div class="likec4-loading__line" style="width: 66.667%"></div>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<style>
|
|
25
|
+
.likec4-loading {
|
|
26
|
+
border: 1px dashed var(--sl-color-gray-5);
|
|
27
|
+
border-radius: 0.5rem;
|
|
28
|
+
background: var(--sl-color-bg);
|
|
29
|
+
padding: 1.25rem;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.likec4-loading__label {
|
|
33
|
+
margin-bottom: 1rem;
|
|
34
|
+
font-size: 0.875rem;
|
|
35
|
+
line-height: 1.25rem;
|
|
36
|
+
font-weight: 500;
|
|
37
|
+
color: var(--sl-color-gray-2);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.likec4-loading__lines {
|
|
41
|
+
display: grid;
|
|
42
|
+
gap: 0.5rem;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.likec4-loading__line {
|
|
46
|
+
height: 1rem;
|
|
47
|
+
border-radius: 0.25rem;
|
|
48
|
+
background: var(--sl-color-gray-6);
|
|
49
|
+
animation: likec4-pulse 1.6s ease-in-out infinite;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@keyframes likec4-pulse {
|
|
53
|
+
0%,
|
|
54
|
+
100% {
|
|
55
|
+
opacity: 1;
|
|
56
|
+
}
|
|
57
|
+
50% {
|
|
58
|
+
opacity: 0.45;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@media (prefers-reduced-motion: reduce) {
|
|
63
|
+
.likec4-loading__line {
|
|
64
|
+
animation: none;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
</style>
|
|
@@ -0,0 +1,68 @@
|
|
|
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.
|
|
13
|
+
|
|
14
|
+
/** Everything after a `/public/` segment, which Astro serves at the site root. */
|
|
15
|
+
const PUBLIC_SEGMENT = /\/public\/(.+)$/;
|
|
16
|
+
|
|
17
|
+
/** A path the compiler resolved on the build machine, rather than a pack id. */
|
|
18
|
+
const LOCAL_PATH = /^(file:\/\/|virtual:\/)/;
|
|
19
|
+
|
|
20
|
+
export type IconSource =
|
|
21
|
+
| { kind: 'pack' }
|
|
22
|
+
| { kind: 'url'; src: string }
|
|
23
|
+
| { kind: 'unresolvable'; path: string };
|
|
24
|
+
|
|
25
|
+
/**
|
|
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.
|
|
34
|
+
*/
|
|
35
|
+
export function classifyIcon(icon: string | null | undefined): IconSource {
|
|
36
|
+
const value = icon?.trim();
|
|
37
|
+
|
|
38
|
+
if (!value) {
|
|
39
|
+
return { kind: 'pack' };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Absolute URLs (and protocol-relative ones) are already web-addressable.
|
|
43
|
+
if (/^(https?:)?\/\//i.test(value)) {
|
|
44
|
+
return { kind: 'url', src: value };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!LOCAL_PATH.test(value)) {
|
|
48
|
+
return { kind: 'pack' };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `file:///c%3A/.../public/likec4-icons/x.svg` -> `/likec4-icons/x.svg`
|
|
52
|
+
const decoded = safeDecode(value);
|
|
53
|
+
const match = PUBLIC_SEGMENT.exec(decoded);
|
|
54
|
+
|
|
55
|
+
if (match) {
|
|
56
|
+
return { kind: 'url', src: `/${match[1]}` };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { kind: 'unresolvable', path: decoded };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function safeDecode(value: string): string {
|
|
63
|
+
try {
|
|
64
|
+
return decodeURIComponent(value);
|
|
65
|
+
} catch {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
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.
|
|
6
|
+
|
|
7
|
+
import { translations } from '../translations.mjs';
|
|
8
|
+
|
|
9
|
+
export type TranslationKey = keyof (typeof translations)['en'];
|
|
10
|
+
export type Translate = (key: TranslationKey) => string;
|
|
11
|
+
|
|
12
|
+
/** Every string an island can show after hydration. */
|
|
13
|
+
export type IslandLabels = {
|
|
14
|
+
loading: string;
|
|
15
|
+
errorTitle: string;
|
|
16
|
+
errorBody: string;
|
|
17
|
+
errorRetry: string;
|
|
18
|
+
viewIdRequired: string;
|
|
19
|
+
noProjects: string;
|
|
20
|
+
/** Template: `{projectId}`, `{available}`. */
|
|
21
|
+
unknownProject: string;
|
|
22
|
+
unresolvedProject: string;
|
|
23
|
+
/** Template: `{projectId}`. */
|
|
24
|
+
loadFailed: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const english: Translate = (key) => translations.en[key];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Wrap `Astro.locals.t`, falling back to English when it is absent — which it
|
|
31
|
+
* is on any page Starlight does not render, such as a plain `src/pages/` route.
|
|
32
|
+
*/
|
|
33
|
+
export function getTranslator(t: unknown): Translate {
|
|
34
|
+
if (typeof t !== 'function') return english;
|
|
35
|
+
|
|
36
|
+
return (key) => {
|
|
37
|
+
const value: unknown = t(key);
|
|
38
|
+
|
|
39
|
+
return typeof value === 'string' && value.length > 0 ? value : english(key);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function getIslandLabels(t: Translate, loadingKey: TranslationKey): IslandLabels {
|
|
44
|
+
return {
|
|
45
|
+
loading: t(loadingKey),
|
|
46
|
+
errorTitle: t('likec4.error.title'),
|
|
47
|
+
errorBody: t('likec4.error.body'),
|
|
48
|
+
errorRetry: t('likec4.error.retry'),
|
|
49
|
+
viewIdRequired: t('likec4.error.viewIdRequired'),
|
|
50
|
+
noProjects: t('likec4.error.noProjects'),
|
|
51
|
+
unknownProject: t('likec4.error.unknownProject'),
|
|
52
|
+
unresolvedProject: t('likec4.error.unresolvedProject'),
|
|
53
|
+
loadFailed: t('likec4.error.loadFailed'),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const DEFAULT_LABELS: IslandLabels = getIslandLabels(english, 'likec4.view.loading');
|
|
58
|
+
|
|
59
|
+
/** Fill `{name}` placeholders. Unknown names are left in place so they show up. */
|
|
60
|
+
export function format(template: string, values: Record<string, string>): string {
|
|
61
|
+
return template.replace(/\{(\w+)\}/g, (match, name: string) => values[name] ?? match);
|
|
62
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
import type { ElementIconRenderer } from 'likec4:icons';
|
|
6
|
+
|
|
7
|
+
import { classifyIcon } from './icon-source';
|
|
8
|
+
|
|
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
|
+
export function withImageIcons(packRenderer: ElementIconRenderer): ElementIconRenderer {
|
|
15
|
+
return function IconRenderer(props) {
|
|
16
|
+
const source = classifyIcon(props.node.icon);
|
|
17
|
+
|
|
18
|
+
if (source.kind === 'url') {
|
|
19
|
+
// The virtual `likec4:icons` module's props carry no className, so size
|
|
20
|
+
// the image to its slot explicitly rather than relying on a class.
|
|
21
|
+
return (
|
|
22
|
+
<img
|
|
23
|
+
src={source.src}
|
|
24
|
+
alt=""
|
|
25
|
+
loading="lazy"
|
|
26
|
+
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
|
27
|
+
/>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (source.kind === 'unresolvable') {
|
|
32
|
+
console.warn(
|
|
33
|
+
`[likec4] Element "${props.node.id}" has icon "${source.path}", which only exists on the machine that built the site. ` +
|
|
34
|
+
'Use a bundled icon (azure:, aws:, gcp:, tech:, bootstrap:), an absolute URL, or a file under public/.',
|
|
35
|
+
);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return packRenderer(props);
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
import { DEFAULT_LABELS, format, type IslandLabels } from './labels';
|
|
7
|
+
|
|
8
|
+
type ErrorLabels = Pick<
|
|
9
|
+
IslandLabels,
|
|
10
|
+
'viewIdRequired' | 'noProjects' | 'unknownProject' | 'loadFailed'
|
|
11
|
+
>;
|
|
12
|
+
|
|
13
|
+
export function getAvailableProjectIds(
|
|
14
|
+
projectDefinitions: ReadonlyArray<{ id: unknown }>,
|
|
15
|
+
): string[] {
|
|
16
|
+
return projectDefinitions
|
|
17
|
+
.map((project) => String(project.id).trim())
|
|
18
|
+
.filter((id) => id.length > 0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function validateViewId(viewId: string, labels: ErrorLabels = DEFAULT_LABELS): string | null {
|
|
22
|
+
if (!viewId || viewId.trim().length === 0) {
|
|
23
|
+
return labels.viewIdRequired;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function resolveProjectId(
|
|
30
|
+
projectId: string | undefined,
|
|
31
|
+
availableProjectIds: string[],
|
|
32
|
+
labels: ErrorLabels = DEFAULT_LABELS,
|
|
33
|
+
) {
|
|
34
|
+
if (availableProjectIds.length === 0) {
|
|
35
|
+
return {
|
|
36
|
+
resolvedProjectId: null,
|
|
37
|
+
error: labels.noProjects,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const resolvedProjectId = projectId?.trim() || availableProjectIds[0]!;
|
|
42
|
+
|
|
43
|
+
if (!availableProjectIds.includes(resolvedProjectId)) {
|
|
44
|
+
return {
|
|
45
|
+
resolvedProjectId: null,
|
|
46
|
+
error: format(labels.unknownProject, {
|
|
47
|
+
projectId: resolvedProjectId,
|
|
48
|
+
available: availableProjectIds.join(', ') || '(none)',
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
resolvedProjectId,
|
|
55
|
+
error: null,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function toLoadErrorMessage(
|
|
60
|
+
err: unknown,
|
|
61
|
+
resolvedProjectId: string,
|
|
62
|
+
labels: ErrorLabels = DEFAULT_LABELS,
|
|
63
|
+
): string {
|
|
64
|
+
if (err instanceof Error && err.message.trim().length > 0) {
|
|
65
|
+
return err.message;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return format(labels.loadFailed, { projectId: resolvedProjectId });
|
|
69
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// expressive-code.d.mts
|
|
2
|
+
// path: src/expressive-code.d.mts
|
|
3
|
+
// Types for src/expressive-code.mjs — see src/index.d.mts for why they exist.
|
|
4
|
+
|
|
5
|
+
import type { LanguageRegistration } from 'shiki';
|
|
6
|
+
|
|
7
|
+
/** Shiki registration for LikeC4, under `likec4` with a `c4` alias. */
|
|
8
|
+
export declare const likec4Language: LanguageRegistration & { aliases: string[] };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the grammar from likec4/likec4 at the requested ref and return it as
|
|
12
|
+
* a Shiki language registration.
|
|
13
|
+
*/
|
|
14
|
+
export declare function loadLikeC4Language(options?: {
|
|
15
|
+
ref?: string;
|
|
16
|
+
repo?: string;
|
|
17
|
+
offline?: boolean;
|
|
18
|
+
onError?: 'fail' | 'warn' | 'silent';
|
|
19
|
+
cacheTtl?: number;
|
|
20
|
+
refresh?: boolean;
|
|
21
|
+
cacheDir?: string;
|
|
22
|
+
baseUrl?: string;
|
|
23
|
+
timeout?: number;
|
|
24
|
+
root?: string;
|
|
25
|
+
}): Promise<LanguageRegistration & { aliases: string[] }>;
|
|
26
|
+
|
|
27
|
+
export default likec4Language;
|
|
28
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// expressive-code.mjs
|
|
2
|
+
// path: src/expressive-code.mjs
|
|
3
|
+
// LikeC4 syntax highlighting for Expressive Code (Starlight's code blocks).
|
|
4
|
+
// Shiki ships no LikeC4 grammar, so without this .c4 source renders as plain
|
|
5
|
+
// text.
|
|
6
|
+
//
|
|
7
|
+
// The Starlight plugin injects this language into the site's Expressive Code
|
|
8
|
+
// options for you (see src/index.mjs) resolved from the live Langium grammar.
|
|
9
|
+
// The exports here cover the two cases the plugin cannot:
|
|
10
|
+
//
|
|
11
|
+
// - a site that already has an `ec.config.mjs`, which can add the bundled
|
|
12
|
+
// grammar with one line:
|
|
13
|
+
//
|
|
14
|
+
// import { likec4Language } from '@purview-dev/astroc4/expressive-code';
|
|
15
|
+
// export default defineEcConfig({ shiki: { langs: [likec4Language] } });
|
|
16
|
+
//
|
|
17
|
+
// - anything else that needs the grammar programmatically, e.g. a Markdown
|
|
18
|
+
// pipeline of its own: `await loadLikeC4Language({ ref: 'v1.59.4' })`.
|
|
19
|
+
//
|
|
20
|
+
// `likec4Language` is the bundled snapshot, so it works without network
|
|
21
|
+
// access; `loadLikeC4Language` fetches the ref you ask for.
|
|
22
|
+
|
|
23
|
+
import grammar from './grammar/likec4.tmLanguage.json' with { type: 'json' };
|
|
24
|
+
import { resolveLikeC4Grammar } from './grammar/source.mjs';
|
|
25
|
+
import { LANGUAGE_ALIASES, LANGUAGE_NAME } from './grammar/textmate.mjs';
|
|
26
|
+
|
|
27
|
+
/** @type {import('shiki').LanguageRegistration} */
|
|
28
|
+
export const likec4Language = {
|
|
29
|
+
...grammar,
|
|
30
|
+
name: LANGUAGE_NAME,
|
|
31
|
+
aliases: [...LANGUAGE_ALIASES],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the grammar from likec4/likec4 at the ref you ask for and return it
|
|
36
|
+
* as a Shiki language registration.
|
|
37
|
+
* @param {import('./grammar/source.mjs').GrammarSourceOptions} [options]
|
|
38
|
+
* @returns {Promise<import('shiki').LanguageRegistration>}
|
|
39
|
+
*/
|
|
40
|
+
export async function loadLikeC4Language(options) {
|
|
41
|
+
const { grammar: resolved } = await resolveLikeC4Grammar(options);
|
|
42
|
+
|
|
43
|
+
return { ...resolved, name: LANGUAGE_NAME, aliases: [...LANGUAGE_ALIASES] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default likec4Language;
|