@gsa-tts/graymatter-ui 0.3.22 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gsa-tts/graymatter-ui",
3
- "version": "0.3.22",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -47,7 +47,10 @@
47
47
  "@testing-library/jest-dom": "^6.6.3",
48
48
  "@testing-library/svelte": "^5.2.8",
49
49
  "@testing-library/user-event": "^14.6.1",
50
+ "@types/mdast": "^4.0.4",
51
+ "@types/mdx": "^2.0.13",
50
52
  "@types/node": "^22.13.0",
53
+ "@types/unist": "^3.0.3",
51
54
  "@uswds/compile": "1.2.2",
52
55
  "@vitest/coverage-istanbul": "^3.2.4",
53
56
  "astro": "^5.13.7",
@@ -58,15 +61,16 @@
58
61
  "typescript": "5.7.3",
59
62
  "vite": "^6.3.5",
60
63
  "vitest": "^3.0.7",
61
- "@gsa-tts/graymatter-eslint": "0.0.2",
62
64
  "@gsa-tts/graymatter-typescript-config": "0.0.2",
63
- "@gsa-tts/graymatter-vitest-config": "0.0.1"
65
+ "@gsa-tts/graymatter-vitest-config": "0.0.1",
66
+ "@gsa-tts/graymatter-eslint": "0.0.2"
64
67
  },
65
68
  "dependencies": {
66
69
  "@fontsource-variable/archivo": "^5.2.6",
67
70
  "@fontsource/space-mono": "^5.2.8",
68
71
  "@uswds/uswds": "3.12.0",
69
72
  "slugify": "^1.6.6",
73
+ "unist-util-visit": "^5.0.0",
70
74
  "@gsa-tts/graymatter-style-tokens": "0.3.17"
71
75
  },
72
76
  "scripts": {
@@ -6,6 +6,7 @@ import purgecss from 'astro-purgecss';
6
6
  import { join as pathJoin, dirname, resolve } from 'path';
7
7
  import { fileURLToPath } from 'url';
8
8
  import { normalizeTrailingSlash } from '../helpers/string-formatters';
9
+ import { remarkWrapPre } from '../utils/remarkPlugins.js';
9
10
 
10
11
  interface AstroConfigOptions {
11
12
  appDir: string;
@@ -95,11 +96,32 @@ export function createAstroConfig({
95
96
  }
96
97
  : baseViteConfig;
97
98
 
99
+ // Base markdown config with remarkWrapPre plugin for code block wrapping
100
+ const baseMarkdownConfig = {
101
+ remarkPlugins: [remarkWrapPre],
102
+ };
103
+
104
+ // Merge markdown config if provided in overrides
105
+ const mergedMarkdownConfig = overrides.markdown
106
+ ? {
107
+ ...baseMarkdownConfig,
108
+ ...(overrides.markdown as Record<string, unknown>),
109
+ remarkPlugins: [
110
+ ...baseMarkdownConfig.remarkPlugins,
111
+ ...((overrides.markdown as { remarkPlugins?: unknown[] })
112
+ .remarkPlugins || []),
113
+ ],
114
+ }
115
+ : baseMarkdownConfig;
116
+
98
117
  return defineConfig({
99
118
  base: normalizeTrailingSlash(process.env.BASEURL || ''),
100
119
  integrations: process.env.VITEST
101
120
  ? []
102
121
  : overrides.integrations || baseIntegrations,
122
+ // Markdown config with remark plugins (applies to all markdown/MDX processing)
123
+ markdown: mergedMarkdownConfig as typeof baseMarkdownConfig &
124
+ typeof overrides.markdown,
103
125
  experimental: {
104
126
  // Astro Experimental Fonts API for managing custom fonts
105
127
  fonts: [
@@ -134,7 +156,7 @@ export function createAstroConfig({
134
156
  // Spread other overrides (excluding the ones we handle specifically)
135
157
  ...Object.fromEntries(
136
158
  Object.entries(overrides).filter(
137
- ([key]) => !['integrations', 'vite'].includes(key)
159
+ ([key]) => !['integrations', 'vite', 'markdown'].includes(key)
138
160
  )
139
161
  ),
140
162
  });
@@ -0,0 +1,149 @@
1
+ <script lang="ts">
2
+ import { siteName } from '@gsa-tts/graymatter-ui/constants';
3
+ import { getUrlFromBase } from '@gsa-tts/graymatter-ui/helpers';
4
+ import Logo from '@gsa-tts/graymatter-ui/components/Logo.svelte';
5
+ import PlatformFooterMenu from './PlatformFooterMenu.svelte';
6
+ import Button from '@gsa-tts/graymatter-ui/components/Button.svelte';
7
+
8
+ /**
9
+ * PlatformFooter Component
10
+ *
11
+ * The main footer component for the platform, featuring:
12
+ * - Agency branding and logo
13
+ * - Call-to-action section
14
+ * - Footer navigation menus
15
+ *
16
+ * Props:
17
+ * - menus?: FooterMenu[] (optional array of footer menus)
18
+ * - ctaHeading?: string (optional CTA heading text)
19
+ * - ctaButtonText?: string (optional CTA button text)
20
+ * - ctaButtonHref?: string (optional CTA button URL)
21
+ * - showCta?: boolean (whether to show the CTA section)
22
+ * - class?: string (optional additional CSS classes)
23
+ *
24
+ * Example:
25
+ * ```svelte
26
+ * <PlatformFooter {menus} />
27
+ * ```
28
+ */
29
+ interface FooterLink {
30
+ title: string;
31
+ url: string;
32
+ }
33
+
34
+ interface FooterMenu {
35
+ title: string;
36
+ items: Array<FooterLink>;
37
+ }
38
+
39
+ interface Props {
40
+ menus?: FooterMenu[];
41
+ ctaHeading?: string;
42
+ ctaButtonText?: string;
43
+ ctaButtonHref?: string;
44
+ showCta?: boolean;
45
+ class?: string;
46
+ }
47
+
48
+ let {
49
+ menus = [],
50
+ ctaHeading = 'Get your team started today.',
51
+ ctaButtonText = 'Partnerships',
52
+ ctaButtonHref = getUrlFromBase('/partnerships'),
53
+ showCta = true,
54
+ class: className = '',
55
+ }: Props = $props();
56
+ </script>
57
+
58
+ <footer class="ai-footer {className}">
59
+ <div class="ai-maxw-widescreen grid-container">
60
+ {#if showCta}
61
+ <section
62
+ class="usa-section ai-section ai-footer-top"
63
+ aria-labelledby="ai-footer-cta"
64
+ >
65
+ <div
66
+ class="usa-identifier__identity ai-identifier__identity"
67
+ aria-label="Agency description"
68
+ >
69
+ <p
70
+ class="usa-identifier__identity-domain ai-identifier__identity-domain"
71
+ >
72
+ <a href={getUrlFromBase('/')} aria-label="Home">
73
+ <Logo
74
+ aria-label={siteName}
75
+ isInverted={true}
76
+ showServiceMark={true}
77
+ width={68}
78
+ />
79
+ </a>
80
+ </p>
81
+ <p class="usa-identifier__identity-disclaimer text-normal">
82
+ An official website of the U.S.
83
+ <a class="ai-footer-link" href="https://www.gsa.gov/"
84
+ >General Services Administration</a
85
+ >
86
+ </p>
87
+ </div>
88
+ <p class="ai-footer-cta__heading" id="ai-footer-cta">
89
+ {ctaHeading}
90
+ </p>
91
+ <div class="ai-footer-cta__buttons">
92
+ <Button href={ctaButtonHref} theme="inverted" variant="secondary">
93
+ {ctaButtonText}
94
+ </Button>
95
+ </div>
96
+ </section>
97
+ {/if}
98
+
99
+ {#if menus.length > 0}
100
+ <section class="usa-section ai-section">
101
+ <div class="grid-row grid-gap">
102
+ <div class="tablet:grid-col-12">
103
+ <nav aria-label="Footer navigation">
104
+ <div class="grid-row grid-gap">
105
+ {#each menus as menu}
106
+ <div class="tablet:grid-col-4">
107
+ <PlatformFooterMenu content={menu} />
108
+ </div>
109
+ {/each}
110
+ </div>
111
+ </nav>
112
+ </div>
113
+ </div>
114
+ </section>
115
+ {/if}
116
+ </div>
117
+ </footer>
118
+
119
+ <style>
120
+ .ai-footer-link {
121
+ color: var(--ai-footer-color-link-rest);
122
+ }
123
+
124
+ .ai-footer-link:hover {
125
+ color: var(--ai-footer-color-link-hover);
126
+ }
127
+
128
+ .ai-footer {
129
+ background: var(--ai-footer-color-background);
130
+ color: var(--ai-footer-color-text);
131
+ }
132
+
133
+ .ai-footer-cta__heading {
134
+ margin-block: 0 var(--ai-size-24);
135
+ }
136
+
137
+ .ai-footer-top {
138
+ border-bottom: 1px solid var(--ai-footer-color-separator);
139
+ }
140
+
141
+ .ai-identifier__identity {
142
+ padding-bottom: var(--ai-layout-section-spacing-y);
143
+ }
144
+
145
+ .ai-identifier__identity-domain {
146
+ color: var(--ai-footer-color-text);
147
+ font-weight: 600;
148
+ }
149
+ </style>
@@ -0,0 +1,80 @@
1
+ <script lang="ts">
2
+ import { v4 as uuidv4 } from 'uuid';
3
+
4
+ /**
5
+ * PlatformFooterMenu Component
6
+ *
7
+ * A footer menu component that displays a title and list of links.
8
+ * Used within the PlatformFooter component to create organized footer navigation.
9
+ *
10
+ * Props:
11
+ * - content: FooterMenu object containing title and items
12
+ * - class?: string (optional additional CSS classes)
13
+ *
14
+ * Example:
15
+ * ```svelte
16
+ * <PlatformFooterMenu content={menuData} />
17
+ * ```
18
+ */
19
+ interface FooterLink {
20
+ title: string;
21
+ url: string;
22
+ }
23
+
24
+ interface FooterMenu {
25
+ title: string;
26
+ items: Array<FooterLink>;
27
+ }
28
+
29
+ interface Props {
30
+ content: FooterMenu;
31
+ class?: string;
32
+ }
33
+
34
+ let { content, class: className = '' }: Props = $props();
35
+
36
+ // Generate unique ID for accessibility
37
+ const menuLabel = `platform-footer-${uuidv4()}`;
38
+ </script>
39
+
40
+ <div class={className}>
41
+ <p id={menuLabel}>{content.title}</p>
42
+ <ul class="add-list-reset" aria-labelledby={menuLabel}>
43
+ {#each content.items as item}
44
+ <li>
45
+ <a href={item.url}>{item.title}</a>
46
+ </li>
47
+ {/each}
48
+ </ul>
49
+ </div>
50
+
51
+ <style>
52
+ p {
53
+ color: var(--ai-footer-color-text-muted);
54
+ margin-top: 0;
55
+ margin-bottom: var(--ai-size-16);
56
+ }
57
+
58
+ ul {
59
+ margin-bottom: var(--ai-size-32);
60
+ }
61
+
62
+ li {
63
+ display: block;
64
+ margin-bottom: var(--ai-size-16);
65
+ }
66
+
67
+ a {
68
+ color: var(--ai-footer-color-link-rest);
69
+ display: block;
70
+ text-decoration: none;
71
+ }
72
+
73
+ a:hover {
74
+ color: var(--ai-footer-color-link-hover);
75
+ }
76
+
77
+ a:is(:focus, :hover, :active) {
78
+ text-decoration: underline;
79
+ }
80
+ </style>
@@ -12,6 +12,8 @@ export { default as UsaSkipNav } from './UsaSkipNav.svelte';
12
12
  export { default as ApiDocSubNavMenu } from './ApiDocSubNavMenu.svelte';
13
13
  export { default as ApiDocSubNavList } from './ApiDocSubNavList.svelte';
14
14
  export { default as ApiDocSubNavMenuItem } from './ApiDocSubNavMenuItem.svelte';
15
+ export { default as PlatformFooter } from './PlatformFooter.svelte';
16
+ export { default as PlatformFooterMenu } from './PlatformFooterMenu.svelte';
15
17
 
16
18
  // Icon Components
17
19
  export * from './icons/index.js';
@@ -3,6 +3,9 @@ import GlobalAppLayout from './GlobalAppLayout.astro';
3
3
  import ApiDocSubNavMenu from '../components/ApiDocSubNavMenu.svelte';
4
4
  import NavigationInitializer from '../components/NavigationInitializer.svelte';
5
5
  import { getBaseUrl } from '../helpers';
6
+ import { getCollection } from 'astro:content';
7
+ import { render } from 'astro:content';
8
+ import { generateApiDocumentationNavData } from '../utils/generateApiNavData.js';
6
9
 
7
10
  const gtmID = import.meta.env.PUBLIC_GTM_ID;
8
11
  const {
@@ -11,12 +14,43 @@ const {
11
14
  description,
12
15
  openGraphImage,
13
16
  showAppIcons = false,
14
- subNavData = null,
15
17
  disableCodeCopyButtons = false,
16
- postItems = null,
17
18
  profileMenuData = null,
18
19
  hideDiscover = true,
20
+ contentCustomization = undefined,
19
21
  } = Astro.props;
22
+
23
+ // Handle ALL logic internally
24
+ // Get collection
25
+ const rawPosts = await getCollection('apiDocumentation');
26
+
27
+ // Sort by sortOrder
28
+ const sortedPosts = [...rawPosts].sort(
29
+ (a, b) => a.data.sortOrder - b.data.sortOrder
30
+ );
31
+
32
+ // Render posts
33
+ const postItems = await Promise.all(
34
+ sortedPosts.map(async post => {
35
+ const { Content } = await render(post);
36
+ return {
37
+ ...post,
38
+ ContentComponent: Content,
39
+ };
40
+ })
41
+ );
42
+
43
+ // Generate navigation data
44
+ const apiDocsMetadata = sortedPosts.map(doc => ({
45
+ id: doc.id,
46
+ slug: doc.id,
47
+ title: doc.data.title,
48
+ description: doc.data.description,
49
+ sortOrder: doc.data.sortOrder,
50
+ content: doc.body || '',
51
+ }));
52
+
53
+ const subNavData = generateApiDocumentationNavData(apiDocsMetadata);
20
54
  ---
21
55
 
22
56
  <GlobalAppLayout
@@ -55,7 +89,7 @@ const {
55
89
  <article class="article-body">
56
90
  <h1 class="page-title">API Documentation</h1>
57
91
 
58
- <!-- Render postItems if provided -->
92
+ <!-- Render postItems -->
59
93
  {
60
94
  postItems &&
61
95
  postItems.map((post: any) => (
@@ -80,6 +114,116 @@ const {
80
114
  </script>
81
115
  )
82
116
  }
117
+ <!-- Client-side content replacement -->
118
+ <script is:inline define:vars={{ contentCustomization }}>
119
+ if (!contentCustomization) {
120
+ // no customization provided
121
+ } else {
122
+ const customization = contentCustomization;
123
+ function escapeHtml(input) {
124
+ return String(input)
125
+ .replace(/&/g, '&amp;')
126
+ .replace(/</g, '&lt;')
127
+ .replace(/>/g, '&gt;')
128
+ .replace(/"/g, '&quot;')
129
+ .replace(/'/g, '&#39;');
130
+ }
131
+ function linkify(input) {
132
+ // Preserve markdown links [label](url) using placeholders
133
+ const placeholders = [];
134
+ const withPlaceholders = String(input).replace(
135
+ /\[([^\]]+)\]\(((https?:\/\/|www\.)[^\s)]+)\)/gi,
136
+ (m, label, href) => {
137
+ const safeLabel = escapeHtml(label);
138
+ const normalizedHref = href.startsWith('http')
139
+ ? href
140
+ : `https://${href}`;
141
+ const safeHref = escapeHtml(normalizedHref);
142
+ const html = `<a href="${safeHref}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`;
143
+ const token = `__LINK_PLACEHOLDER_${placeholders.length}__`;
144
+ placeholders.push(html);
145
+ return token;
146
+ }
147
+ );
148
+
149
+ // Escape remaining content
150
+ const escaped = escapeHtml(withPlaceholders);
151
+
152
+ // Linkify emails
153
+ const withEmails = escaped.replace(
154
+ /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
155
+ email => {
156
+ return `<a href="mailto:${email}">${email}</a>`;
157
+ }
158
+ );
159
+ // Linkify bare URLs (http/https and www.)
160
+ const urlRegex = /\b((https?:\/\/)[^\s)]+|www\.[^\s)]+)\b/gi;
161
+ const withUrls = withEmails.replace(urlRegex, raw => {
162
+ const href = raw.startsWith('http') ? raw : `https://${raw}`;
163
+ return `<a href="${href}" target="_blank" rel="noopener noreferrer">${raw}</a>`;
164
+ });
165
+
166
+ // Restore placeholders
167
+ return withUrls.replace(
168
+ /__LINK_PLACEHOLDER_(\d+)__/g,
169
+ (m, idx) => placeholders[Number(idx)]
170
+ );
171
+ }
172
+
173
+ function replaceContent() {
174
+ // For endpoints
175
+ if (customization.endpointsMessage) {
176
+ const heading = document.getElementById('endpoints');
177
+ if (heading) {
178
+ const section = heading.closest('div');
179
+ if (section) {
180
+ const paragraphs = section.querySelectorAll('p');
181
+ paragraphs.forEach(p => {
182
+ if (
183
+ p.textContent?.includes(
184
+ 'API endpoints will be available when you login'
185
+ )
186
+ ) {
187
+ p.innerHTML = linkify(customization.endpointsMessage);
188
+ }
189
+ });
190
+ }
191
+ }
192
+ }
193
+
194
+ // For getting-started
195
+ if (customization.authMessage) {
196
+ const heading = document.getElementById('getting-started');
197
+ if (heading) {
198
+ const section = heading.closest('div');
199
+ if (section) {
200
+ const paragraphs = section.querySelectorAll('p');
201
+ paragraphs.forEach(p => {
202
+ const text = p.textContent || '';
203
+ if (text.includes('All API requests require an API key')) {
204
+ const authPattern =
205
+ /All API requests require an API key\. Upon authentication,.*?available\./s;
206
+ if (authPattern.test(text)) {
207
+ p.innerHTML = linkify(customization.authMessage);
208
+ }
209
+ }
210
+ });
211
+ }
212
+ }
213
+ }
214
+ }
215
+
216
+ // Run synchronously before paint
217
+ if (document.readyState === 'loading') {
218
+ document.addEventListener('DOMContentLoaded', replaceContent, {
219
+ once: true,
220
+ });
221
+ } else {
222
+ replaceContent();
223
+ }
224
+ setTimeout(replaceContent, 0);
225
+ }
226
+ </script>
83
227
 
84
228
  <style>
85
229
  body {
@@ -0,0 +1,35 @@
1
+ import { glob } from 'astro/loaders';
2
+
3
+ /**
4
+ * Creates the API documentation collection for Astro apps.
5
+ *
6
+ * @example
7
+ * ```typescript
8
+ * import { defineCollection, z } from 'astro:content';
9
+ * import { createApiDocumentationCollection } from '@gsa-tts/graymatter-ui/utils/apiDocumentationConfig.js';
10
+ *
11
+ * export const collections = {
12
+ * apiDocumentation: createApiDocumentationCollection({ defineCollection, z }),
13
+ * };
14
+ * ```
15
+ */
16
+ export function createApiDocumentationCollection({
17
+ defineCollection,
18
+ z,
19
+ }: {
20
+ defineCollection: typeof import('astro:content').defineCollection;
21
+ z: typeof import('astro:content').z;
22
+ }) {
23
+ return defineCollection({
24
+ loader: glob({
25
+ base: './node_modules/@gsa-tts/graymatter-ui/src/content/api',
26
+ pattern: '**/*.{md,mdx}',
27
+ }),
28
+ schema: () =>
29
+ z.object({
30
+ title: z.string(),
31
+ description: z.string(),
32
+ sortOrder: z.number(),
33
+ }),
34
+ });
35
+ }
@@ -1,3 +1,7 @@
1
+ // Note: Do not export remarkPlugins.js or apiDocumentationConfig.js from here.
2
+ // These are Astro-specific utilities that should only be imported directly by their specific paths.
3
+ // Exporting them would cause bundling issues for Svelte/SvelteKit apps that import from this barrel export.
4
+
1
5
  export * from './getAppUrls.js';
2
6
  export * from './navigationControl.js';
3
7
  export * from './copyButtonScript.js';
@@ -0,0 +1,55 @@
1
+ import { visit } from 'unist-util-visit';
2
+ import type { Root } from 'mdast';
3
+ import type { Code } from 'mdast';
4
+ import type { Parent } from 'unist';
5
+
6
+ /**
7
+ * MDX-specific node type for JSX flow elements
8
+ */
9
+ interface MdxJsxFlowElement extends Parent {
10
+ type: 'mdxJsxFlowElement';
11
+ name: string;
12
+ attributes?: Array<{
13
+ type: 'mdxJsxAttribute';
14
+ name: string;
15
+ value: string;
16
+ }>;
17
+ }
18
+
19
+ /**
20
+ * Remark plugin to wrap code blocks in a div with className 'ai-code-block-wrapper'
21
+ *
22
+ * This enables the copy button functionality for code blocks in documentation.
23
+ *
24
+ * @returns A remark plugin function
25
+ */
26
+ export function remarkWrapPre() {
27
+ return (tree: Root) => {
28
+ visit(
29
+ tree,
30
+ 'code',
31
+ (node: Code, index: number | undefined, parent: Parent | undefined) => {
32
+ if (
33
+ node.type === 'code' &&
34
+ typeof index === 'number' &&
35
+ parent &&
36
+ 'children' in parent
37
+ ) {
38
+ const wrapper: MdxJsxFlowElement = {
39
+ type: 'mdxJsxFlowElement',
40
+ name: 'div',
41
+ attributes: [
42
+ {
43
+ type: 'mdxJsxAttribute',
44
+ name: 'className',
45
+ value: 'ai-code-block-wrapper',
46
+ },
47
+ ],
48
+ children: [node],
49
+ };
50
+ parent.children.splice(index, 1, wrapper);
51
+ }
52
+ }
53
+ );
54
+ };
55
+ }