astro-better-cards 0.1.0 → 0.2.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.
@@ -0,0 +1,148 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import { Marked } from 'marked';
4
+ import Card from './Card.astro';
5
+
6
+ interface Props {
7
+ collection?: string;
8
+ folder?: string;
9
+ depth?: 1 | 2;
10
+ }
11
+
12
+ const { collection = 'docs', folder, depth = 1 } = Astro.props;
13
+
14
+ const pages = await getCollection(collection as any);
15
+
16
+ // Compute collection-relative prefix (no leading/trailing slashes, no extension)
17
+ let prefix: string;
18
+ if (folder !== undefined) {
19
+ if (folder.startsWith('/')) {
20
+ const noSlash = folder.replace(/^\/+/, '');
21
+ prefix = noSlash.startsWith(`${collection}/`) ? noSlash.slice(collection.length + 1) : noSlash;
22
+ } else if (folder.startsWith('./') || folder.startsWith('../')) {
23
+ const pathname = Astro.url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.html$/, '');
24
+ const pagePrefix = pathname.startsWith(`${collection}/`) ? pathname.slice(collection.length + 1) : pathname;
25
+ const pageFolder = pagePrefix.includes('/') ? pagePrefix.replace(/\/[^/]+$/, '') : '';
26
+ const parts = pageFolder ? pageFolder.split('/') : [];
27
+ for (const seg of folder.split('/')) {
28
+ if (seg === '..') parts.pop();
29
+ else if (seg !== '.' && seg !== '') parts.push(seg);
30
+ }
31
+ prefix = parts.join('/');
32
+ } else {
33
+ prefix = folder;
34
+ }
35
+ } else {
36
+ const pathname = Astro.url.pathname.replace(/^\/+|\/+$/g, '').replace(/\.html$/, '');
37
+ prefix = pathname.startsWith(`${collection}/`) ? pathname.slice(collection.length + 1) : pathname;
38
+ }
39
+ prefix = prefix.replace(/^\/+|\/+$/g, '');
40
+
41
+ function stripExt(id: string): string {
42
+ return id.replace(/\.(md|mdx)$/i, '');
43
+ }
44
+
45
+ function isDirectChild(id: string): boolean {
46
+ const cid = stripExt(id);
47
+ if (!prefix) return !cid.includes('/') && cid !== 'index';
48
+ if (!cid.startsWith(`${prefix}/`)) return false;
49
+ const rem = cid.slice(prefix.length + 1);
50
+ return rem.length > 0 && !rem.includes('/') && rem !== 'index';
51
+ }
52
+
53
+ function isGrandchildPage(id: string): boolean {
54
+ const cid = stripExt(id);
55
+ if (!cid.startsWith(`${prefix}/`)) return false;
56
+ const rem = cid.slice(prefix.length + 1);
57
+ const parts = rem.split('/');
58
+ return parts.length === 2 && parts[1] !== 'index';
59
+ }
60
+
61
+ function getSubfolder(id: string): string {
62
+ return stripExt(id).slice(prefix.length + 1).split('/')[0];
63
+ }
64
+
65
+ const remark = new Marked();
66
+ const md = (s: string) => remark.parseInline(s) as Promise<string>;
67
+
68
+ const valid = (pages as any[]).filter(p => !p.data.excludeFromNav && p.data.route !== false);
69
+
70
+ type NavItem = {
71
+ href: string;
72
+ title: string;
73
+ description?: string;
74
+ icon?: string | null;
75
+ darkIcon?: string;
76
+ cardImage?: string;
77
+ };
78
+
79
+ type NavGroup = { title: string; order: number; items: NavItem[] };
80
+
81
+ let items: NavItem[] = [];
82
+ let groups: NavGroup[] = [];
83
+
84
+ if (depth === 1) {
85
+ const children = valid.filter(p => isDirectChild(p.id));
86
+ children.sort((a, b) => (a.data.order ?? 1000) - (b.data.order ?? 1000) || a.data.title.localeCompare(b.data.title));
87
+ items = await Promise.all(children.map(async p => ({
88
+ href: `/${collection}/${stripExt(p.id).replace(/\/index$/, '')}`,
89
+ title: await md(p.data.title),
90
+ description: p.data.description,
91
+ icon: p.data.icon ?? null,
92
+ darkIcon: p.data.darkIcon,
93
+ cardImage: p.data.cardImage,
94
+ })));
95
+ } else {
96
+ const grandchildren = valid.filter(p => isGrandchildPage(p.id));
97
+ const subfolderNames = [...new Set(grandchildren.map(p => getSubfolder(p.id)))];
98
+
99
+ groups = await Promise.all(subfolderNames.map(async sf => {
100
+ const indexEntry = (pages as any[]).find(p => stripExt(p.id) === `${prefix}/${sf}/index`);
101
+ const sectionTitle: string = indexEntry
102
+ ? await md(indexEntry.data.title)
103
+ : sf.replace(/-/g, ' ').replace(/\b\w/g, (c: string) => c.toUpperCase());
104
+ const sectionOrder: number = indexEntry?.data.order ?? 1000;
105
+
106
+ const sfItems = grandchildren.filter(p => getSubfolder(p.id) === sf);
107
+ sfItems.sort((a, b) => (a.data.order ?? 1000) - (b.data.order ?? 1000) || a.data.title.localeCompare(b.data.title));
108
+
109
+ return {
110
+ title: sectionTitle,
111
+ order: sectionOrder,
112
+ items: await Promise.all(sfItems.map(async p => ({
113
+ href: `/${collection}/${stripExt(p.id)}`,
114
+ title: await md(p.data.title),
115
+ description: p.data.description,
116
+ icon: p.data.icon ?? null,
117
+ darkIcon: p.data.darkIcon,
118
+ cardImage: p.data.cardImage,
119
+ }))),
120
+ };
121
+ }));
122
+
123
+ groups.sort((a, b) => a.order - b.order);
124
+ }
125
+ ---
126
+
127
+ {depth === 1 ? (
128
+ <div class="gap-6 mt-6 grid grid-cols-1 not-prose lg:grid-cols-2" xmlns="http://www.w3.org/1999/xhtml">
129
+ {items.map(item => (
130
+ <Card href={item.href} title={item.title} description={item.description}
131
+ icon={item.icon} darkIcon={item.darkIcon} cardImage={item.cardImage} variant="full" />
132
+ ))}
133
+ </div>
134
+ ) : (
135
+ <Fragment>
136
+ {groups.map(group => (
137
+ <Fragment>
138
+ <h2 class="mt-10 mb-2 text-xl font-semibold not-prose" set:html={group.title} />
139
+ <div class="gap-6 grid grid-cols-1 not-prose lg:grid-cols-2" xmlns="http://www.w3.org/1999/xhtml">
140
+ {group.items.map(item => (
141
+ <Card href={item.href} title={item.title} description={item.description}
142
+ icon={item.icon} darkIcon={item.darkIcon} cardImage={item.cardImage} variant="full" />
143
+ ))}
144
+ </div>
145
+ </Fragment>
146
+ ))}
147
+ </Fragment>
148
+ )}
package/PageNav.astro ADDED
@@ -0,0 +1,72 @@
1
+ ---
2
+ import { getCollection } from 'astro:content';
3
+ import { Marked } from 'marked';
4
+
5
+ interface Props {
6
+ collection?: string;
7
+ currentId: string;
8
+ nextPage?: string;
9
+ lastPage?: string;
10
+ }
11
+
12
+ const { collection = 'docs', currentId, nextPage, lastPage } = Astro.props;
13
+
14
+ const pages = await getCollection(collection as any);
15
+ const remark = new Marked();
16
+
17
+ const currentClean = currentId.replace(/\.(md|mdx)$/i, '');
18
+ const currentFolder = currentClean.includes('/') ? currentClean.replace(/\/[^/]+$/, '') : '';
19
+
20
+ function stripExt(id: string): string {
21
+ return id.replace(/\.(md|mdx)$/i, '');
22
+ }
23
+
24
+ function findPage(href: string) {
25
+ const target = href.startsWith('/')
26
+ ? href.replace(new RegExp(`^\\/${collection}\\/`), '')
27
+ : currentFolder ? `${currentFolder}/${href}` : href;
28
+ return (pages as any[]).find(p => {
29
+ const cid = stripExt(p.id);
30
+ return cid === target || cid === `${target}/index`;
31
+ });
32
+ }
33
+
34
+ function makeHref(href: string): string {
35
+ if (href.startsWith('/')) return href;
36
+ return `/${collection}/${currentFolder ? `${currentFolder}/` : ''}${href}`;
37
+ }
38
+
39
+ const nextEntry = nextPage ? findPage(nextPage) : null;
40
+ const lastEntry = lastPage ? findPage(lastPage) : null;
41
+
42
+ const nextTitle: string | null = nextEntry ? await remark.parseInline(nextEntry.data.title) as string : null;
43
+ const lastTitle: string | null = lastEntry ? await remark.parseInline(lastEntry.data.title) as string : null;
44
+
45
+ const nextHref = nextPage ? makeHref(nextPage) : null;
46
+ const lastHref = lastPage ? makeHref(lastPage) : null;
47
+ ---
48
+
49
+ <nav class="not-prose mt-10 pt-6 border-t border-slate-200 dark:border-slate-700" aria-label="Page navigation">
50
+ <div class="grid grid-cols-2 gap-4">
51
+ {lastEntry ? (
52
+ <a href={lastHref}
53
+ class="group flex flex-col gap-1 rounded-lg border border-slate-200 dark:border-slate-700 p-4 no-underline hover:border-indigo-500 dark:hover:border-indigo-400 transition-colors">
54
+ <span class="flex items-center gap-1 text-xs font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
55
+ <span aria-hidden="true">&larr;</span> Previous
56
+ </span>
57
+ <span class="text-sm font-medium text-slate-800 dark:text-slate-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400"
58
+ set:html={lastTitle} />
59
+ </a>
60
+ ) : <div />}
61
+ {nextEntry ? (
62
+ <a href={nextHref}
63
+ class="group flex flex-col gap-1 rounded-lg border border-slate-200 dark:border-slate-700 p-4 text-right no-underline hover:border-indigo-500 dark:hover:border-indigo-400 transition-colors">
64
+ <span class="flex items-center justify-end gap-1 text-xs font-semibold uppercase tracking-wide text-slate-400 dark:text-slate-500">
65
+ Next <span aria-hidden="true">&rarr;</span>
66
+ </span>
67
+ <span class="text-sm font-medium text-slate-800 dark:text-slate-200 group-hover:text-indigo-600 dark:group-hover:text-indigo-400"
68
+ set:html={nextTitle} />
69
+ </a>
70
+ ) : <div />}
71
+ </div>
72
+ </nav>
package/README.md CHANGED
@@ -1,72 +1,91 @@
1
1
  # astro-better-cards
2
2
 
3
- Clickable card components for Astro docs sites. Three variants: `full` (large card with optional image/icon), `compact` (nav row with optional label), and `quickstart` (small icon + title, with coming-soon support).
3
+ Components for cards and navigation in Astro documentation sites.
4
4
 
5
- ## Install
5
+ ## Card
6
6
 
7
- ```
8
- npm install astro-better-cards
9
- ```
10
-
11
- ## Usage
7
+ A single card with three display variants.
12
8
 
13
- ```mdx
9
+ ```astro
14
10
  import Card from 'astro-better-cards/Card.astro';
11
+
12
+ <Card
13
+ href="/docs/section/page"
14
+ title="Page Title"
15
+ description="Optional description text."
16
+ icon="/img/icons/example.svg"
17
+ variant="full"
18
+ />
15
19
  ```
16
20
 
17
- ### full (default)
21
+ ### Props
18
22
 
19
- Large bordered card. Good for section index pages.
23
+ | Prop | Type | Default | Description |
24
+ |------|------|---------|-------------|
25
+ | `href` | `string` | required | Link destination |
26
+ | `title` | `string` | required | Card title (HTML allowed) |
27
+ | `description` | `string` | — | Subtitle text |
28
+ | `icon` | `string` | — | Icon image URL (light mode) |
29
+ | `darkIcon` | `string` | — | Icon image URL (dark mode) |
30
+ | `cardImage` | `string` | — | Hero image URL |
31
+ | `variant` | `'full' \| 'compact' \| 'quickstart'` | `'full'` | Display style |
32
+ | `comingSoon` | `boolean` | `false` | Greys out the card |
33
+ | `label` | `string` | — | Small label text (e.g. `'->'`) |
34
+ | `labelFirst` | `boolean` | `false` | Render label before title |
20
35
 
21
- ```mdx
22
- <Card href="/docs/section" title="Section Title" description="A short description." />
23
- ```
36
+ ## ChildCards
24
37
 
25
- With an icon:
38
+ Automatically renders cards for child pages of the current section, pulled from an Astro content collection.
26
39
 
27
- ```mdx
28
- <Card href="/docs/section" title="Section Title" icon="/img/icon.svg" darkIcon="/img/icon-dark.svg" />
29
- ```
40
+ ```astro
41
+ import ChildCards from 'astro-better-cards/ChildCards.astro';
30
42
 
31
- With a card image (displayed above the content):
43
+ <!-- direct children of the current page's folder -->
44
+ <ChildCards />
32
45
 
33
- ```mdx
34
- <Card href="/docs/section" title="Section Title" cardImage="/img/banner.png" />
46
+ <!-- grandchildren grouped by subfolder with section headers -->
47
+ <ChildCards depth={2} />
48
+
49
+ <!-- children of an explicit folder -->
50
+ <ChildCards folder="get-started/quickstarts/web" />
51
+
52
+ <!-- children from a different collection -->
53
+ <ChildCards collection="articles" />
35
54
  ```
36
55
 
37
- ### compact
56
+ Typically used via the `sectionIndex: true` front matter field (rendered automatically by the layout), or imported directly in MDX for more control.
38
57
 
39
- Small nav row, often used for prev/next links. The `label` prop renders a short badge (e.g. an arrow). `labelFirst` controls which side the label appears on.
58
+ ### Props
40
59
 
41
- ```mdx
42
- <Card variant="compact" href="/docs/next-page" title="Next Page" label="->" labelFirst={true} />
43
- <Card variant="compact" href="/docs/prev-page" title="Previous Page" label="<-" labelFirst={false} />
44
- ```
60
+ | Prop | Type | Default | Description |
61
+ |------|------|---------|-------------|
62
+ | `collection` | `string` | `'docs'` | Astro content collection name |
63
+ | `folder` | `string` | current page folder | Collection-relative path (`get-started/foo`), URL-absolute path (`/docs/get-started/foo`), or relative path (`./sub`, `../other`) |
64
+ | `depth` | `1 \| 2` | `1` | `1` = direct children; `2` = grandchildren grouped under section headers |
45
65
 
46
- ### quickstart
66
+ Pages with `excludeFromNav: true` or `route: false` are excluded.
47
67
 
48
- Small icon + title card for quickstart grids. Supports a `comingSoon` state that greys out the card.
68
+ ## PageNav
49
69
 
50
- ```mdx
51
- <Card variant="quickstart" href="/docs/quickstart/react" title="React" icon="/img/react.svg" />
52
- <Card variant="quickstart" href="" title="Vue" icon="/img/vue.svg" comingSoon={true} />
53
- ```
70
+ Renders previous/next navigation links at the bottom of a page, resolving page titles automatically from the collection.
71
+
72
+ ```astro
73
+ import PageNav from 'astro-better-cards/PageNav.astro';
54
74
 
55
- ## Props
75
+ <PageNav
76
+ currentId={entry.id}
77
+ lastPage="step-1"
78
+ nextPage="step-3"
79
+ />
80
+ ```
56
81
 
57
- | Prop | Variants | Type | Default | Description |
58
- |------|----------|------|---------|-------------|
59
- | `href` | all | `string` | — | Link destination. |
60
- | `title` | all | `string` | — | Card title. Rendered with `set:html` so HTML entities and inline markup work. |
61
- | `variant` | all | `'full' \| 'compact' \| 'quickstart'` | `'full'` | Which card style to render. |
62
- | `description` | `full` | `string` | — | Optional subtitle below the title. |
63
- | `icon` | `full`, `quickstart` | `string` | — | Icon image URL. Hidden in dark mode when `darkIcon` is also set. |
64
- | `darkIcon` | `full` | `string` | — | Dark-mode icon image URL. |
65
- | `cardImage` | `full` | `string` | — | Banner image displayed above the card content. |
66
- | `label` | `compact` | `string` | — | Short badge text (e.g. `"->"`, `"<-"`, a step number). |
67
- | `labelFirst` | `compact` | `boolean` | `false` | When `true`, the label appears after the title (right-aligned). |
68
- | `comingSoon` | `quickstart` | `boolean` | `false` | Greys out the card and appends `* Coming Soon` to the title. |
82
+ Or set `lastPage` / `nextPage` in front matter and let the layout render it automatically.
69
83
 
70
- ## Styling
84
+ ### Props
71
85
 
72
- Cards use Tailwind CSS utility classes and respond to dark mode via the `dark:` variant. No Tailwind integration is required in your project -- classes are inlined in the component.
86
+ | Prop | Type | Default | Description |
87
+ |------|------|---------|-------------|
88
+ | `collection` | `string` | `'docs'` | Astro content collection name |
89
+ | `currentId` | `string` | required | The current page's `entry.id` (e.g. `get-started/start-here/step-1.mdx`) |
90
+ | `nextPage` | `string` | — | Relative href to the next page (e.g. `step-2`) or absolute (`/docs/...`) |
91
+ | `lastPage` | `string` | — | Relative href to the previous page |
package/package.json CHANGED
@@ -1,9 +1,14 @@
1
1
  {
2
2
  "name": "astro-better-cards",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "exports": {
6
- "./Card.astro": "./Card.astro"
6
+ "./Card.astro": "./Card.astro",
7
+ "./ChildCards.astro": "./ChildCards.astro",
8
+ "./PageNav.astro": "./PageNav.astro"
9
+ },
10
+ "dependencies": {
11
+ "marked": "^12.0.0"
7
12
  },
8
13
  "peerDependencies": {
9
14
  "astro": ">=4.0.0"