astro-awesomeness 1.2.2 → 2.0.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/dist/components/index.d.ts +3 -9
- package/dist/components/index.js +23 -23
- package/dist/components/index.js.map +1 -1
- package/dist/index-B8l1pSeN.d.ts +84 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/index.d.ts +2 -2
- package/dist/lib/index.js +2 -3
- package/dist/{lib-D7HdNeEs.js → lib-Edmq5pOZ.js} +116 -39
- package/dist/lib-Edmq5pOZ.js.map +1 -0
- package/dist/styles/globals.css +64 -1
- package/package.json +12 -11
- package/src/astro/header.astro +1 -1
- package/src/astro/post-card.astro +0 -8
- package/src/astro/seo.astro +7 -2
- package/src/astro/tag-list.astro +3 -3
- package/src/components/button.tsx +56 -0
- package/src/components/index.ts +2 -3
- package/src/{components → compositions}/theme-toggle.tsx +17 -2
- package/src/layouts/base-layout.astro +8 -1
- package/src/layouts/list-layout.astro +4 -42
- package/src/lib/category-index.test.ts +99 -0
- package/src/lib/category-index.ts +97 -0
- package/src/lib/cn.test.ts +2 -2
- package/src/lib/index.ts +3 -1
- package/src/lib/tag-url.test.ts +32 -1
- package/src/lib/tag-url.ts +15 -1
- package/src/styles/globals.css +64 -1
- package/dist/cn-B2A3VjBM.js +0 -3
- package/dist/index-iwkIePdz.d.ts +0 -57
- package/dist/lib-D7HdNeEs.js.map +0 -1
- package/src/components/ui/button-variants.ts +0 -35
- package/src/components/ui/button.tsx +0 -25
package/src/astro/header.astro
CHANGED
|
@@ -27,11 +27,3 @@ const href = postHref ? postHref(post) : `/blog/${post.id}`;
|
|
|
27
27
|
</a>
|
|
28
28
|
{tags.length > 0 && <TagList tags={tags} class="mt-3" />}
|
|
29
29
|
</article>
|
|
30
|
-
|
|
31
|
-
<style>
|
|
32
|
-
/* Keep first 3 eager so above-the-fold paint isn't gated on containment checks. */
|
|
33
|
-
.post-card:nth-child(n + 4) {
|
|
34
|
-
content-visibility: auto;
|
|
35
|
-
contain-intrinsic-size: auto 200px;
|
|
36
|
-
}
|
|
37
|
-
</style>
|
package/src/astro/seo.astro
CHANGED
|
@@ -6,8 +6,10 @@ type Props = {
|
|
|
6
6
|
image?: string;
|
|
7
7
|
locale?: string;
|
|
8
8
|
modifiedTime?: Date;
|
|
9
|
+
noindex?: boolean;
|
|
9
10
|
publishedTime?: Date;
|
|
10
11
|
siteName?: string;
|
|
12
|
+
siteTitle?: string;
|
|
11
13
|
title: string;
|
|
12
14
|
type?: "website" | "article";
|
|
13
15
|
};
|
|
@@ -19,17 +21,20 @@ const {
|
|
|
19
21
|
image,
|
|
20
22
|
locale = "en_US",
|
|
21
23
|
modifiedTime,
|
|
24
|
+
noindex = false,
|
|
22
25
|
publishedTime,
|
|
23
26
|
siteName,
|
|
27
|
+
siteTitle,
|
|
24
28
|
title,
|
|
25
29
|
type = "website",
|
|
26
30
|
} = Astro.props;
|
|
27
31
|
const url = canonical ?? new URL(Astro.url.pathname, Astro.site ?? Astro.url.origin).toString();
|
|
32
|
+
const documentTitle = siteTitle !== undefined && title !== siteTitle ? `${title} · ${siteTitle}` : title;
|
|
28
33
|
---
|
|
29
|
-
<title>{
|
|
34
|
+
<title>{documentTitle}</title>
|
|
30
35
|
<meta name="description" content={description} />
|
|
31
36
|
{authorName && <meta name="author" content={authorName} />}
|
|
32
|
-
<link rel="canonical" href={url} />
|
|
37
|
+
{noindex ? <meta name="robots" content="noindex,follow" /> : <link rel="canonical" href={url} />}
|
|
33
38
|
|
|
34
39
|
<meta property="og:type" content={type} />
|
|
35
40
|
<meta property="og:title" content={title} />
|
package/src/astro/tag-list.astro
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { DEFAULT_TAG_BASE, tagUrl } from "../lib/tag-url";
|
|
2
|
+
import { DEFAULT_TAG_BASE, tagLabel, tagUrl, uniqueTags } from "../lib/tag-url";
|
|
3
3
|
import { cn } from "../lib/cn";
|
|
4
4
|
|
|
5
5
|
type Props = {
|
|
@@ -11,13 +11,13 @@ type Props = {
|
|
|
11
11
|
const { base = DEFAULT_TAG_BASE, class: className = "", tags } = Astro.props;
|
|
12
12
|
---
|
|
13
13
|
<ul class={cn("flex flex-wrap gap-2", className)}>
|
|
14
|
-
{tags.map((tag) => (
|
|
14
|
+
{uniqueTags(tags).map((tag) => (
|
|
15
15
|
<li>
|
|
16
16
|
<a
|
|
17
17
|
href={tagUrl(tag, base)}
|
|
18
18
|
class="border-border bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground inline-flex items-center rounded-md border px-2 py-0.5 text-xs transition-colors"
|
|
19
19
|
>
|
|
20
|
-
{tag}
|
|
20
|
+
{tagLabel(tag)}
|
|
21
21
|
</a>
|
|
22
22
|
</li>
|
|
23
23
|
))}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Button as ButtonPrimitive } from "@base-ui/react/button";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "cn";
|
|
4
|
+
|
|
5
|
+
const buttonVariants = cva(
|
|
6
|
+
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
7
|
+
{
|
|
8
|
+
defaultVariants: {
|
|
9
|
+
size: "default",
|
|
10
|
+
variant: "default",
|
|
11
|
+
},
|
|
12
|
+
variants: {
|
|
13
|
+
size: {
|
|
14
|
+
default:
|
|
15
|
+
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
16
|
+
icon: "size-8",
|
|
17
|
+
"icon-lg": "size-9",
|
|
18
|
+
"icon-sm": "size-7 rounded-control in-data-[slot=button-group]:rounded-lg",
|
|
19
|
+
"icon-xs":
|
|
20
|
+
"size-6 rounded-control-sm in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
21
|
+
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
22
|
+
sm: "h-7 gap-1 rounded-control px-2.5 text-(length:--text-control) in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
23
|
+
xs: "h-6 gap-1 rounded-control-sm px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
24
|
+
},
|
|
25
|
+
variant: {
|
|
26
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
|
27
|
+
destructive:
|
|
28
|
+
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
29
|
+
ghost:
|
|
30
|
+
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
31
|
+
link: "text-primary underline-offset-4 hover:underline",
|
|
32
|
+
outline:
|
|
33
|
+
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
34
|
+
secondary:
|
|
35
|
+
"bg-secondary text-secondary-foreground hover:bg-secondary-hover aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const Button = ({
|
|
42
|
+
className,
|
|
43
|
+
size = "default",
|
|
44
|
+
variant = "default",
|
|
45
|
+
...props
|
|
46
|
+
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) => {
|
|
47
|
+
return (
|
|
48
|
+
<ButtonPrimitive
|
|
49
|
+
className={cn(buttonVariants({ className, size, variant }))}
|
|
50
|
+
data-slot="button"
|
|
51
|
+
{...props}
|
|
52
|
+
/>
|
|
53
|
+
);
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export { Button, buttonVariants };
|
package/src/components/index.ts
CHANGED
|
@@ -1,3 +1,2 @@
|
|
|
1
|
-
export { Button } from "./
|
|
2
|
-
export {
|
|
3
|
-
export { ThemeToggle } from "./theme-toggle";
|
|
1
|
+
export { Button, buttonVariants } from "./button";
|
|
2
|
+
export { ThemeToggle } from "../compositions/theme-toggle";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Moon, Sun } from "lucide-react";
|
|
2
2
|
import { useSyncExternalStore } from "react";
|
|
3
3
|
|
|
4
|
-
import { Button } from "
|
|
4
|
+
import { Button } from "../components/button";
|
|
5
5
|
|
|
6
6
|
type Theme = "light" | "dark";
|
|
7
7
|
|
|
@@ -59,7 +59,16 @@ type Props = {
|
|
|
59
59
|
ariaLabel?: string;
|
|
60
60
|
};
|
|
61
61
|
|
|
62
|
+
const subscribeHydration = () => () => {};
|
|
63
|
+
const getHydratedSnapshot = () => true;
|
|
64
|
+
const getServerHydratedSnapshot = () => false;
|
|
65
|
+
|
|
62
66
|
const ThemeToggle = ({ ariaLabel = "Toggle theme" }: Props) => {
|
|
67
|
+
const isHydrated = useSyncExternalStore(
|
|
68
|
+
subscribeHydration,
|
|
69
|
+
getHydratedSnapshot,
|
|
70
|
+
getServerHydratedSnapshot,
|
|
71
|
+
);
|
|
63
72
|
const prefersDark = useSyncExternalStore(subscribePrefersDark, getPrefersDark, () => false);
|
|
64
73
|
const override = useSyncExternalStore(subscribeStoredTheme, getStoredTheme, () => null);
|
|
65
74
|
const theme: Theme = override ?? (prefersDark ? "dark" : "light");
|
|
@@ -71,7 +80,13 @@ const ThemeToggle = ({ ariaLabel = "Toggle theme" }: Props) => {
|
|
|
71
80
|
};
|
|
72
81
|
|
|
73
82
|
return (
|
|
74
|
-
<Button
|
|
83
|
+
<Button
|
|
84
|
+
aria-label={ariaLabel}
|
|
85
|
+
disabled={!isHydrated}
|
|
86
|
+
onClick={handleToggleTheme}
|
|
87
|
+
size="icon"
|
|
88
|
+
variant="ghost"
|
|
89
|
+
>
|
|
75
90
|
{theme === "dark" ? <Sun /> : <Moon />}
|
|
76
91
|
</Button>
|
|
77
92
|
);
|
|
@@ -10,6 +10,7 @@ type Props = {
|
|
|
10
10
|
lang?: string;
|
|
11
11
|
locale?: string;
|
|
12
12
|
modifiedTime?: Date;
|
|
13
|
+
noindex?: boolean;
|
|
13
14
|
publishedTime?: Date;
|
|
14
15
|
rssHref?: string;
|
|
15
16
|
siteName?: string;
|
|
@@ -27,6 +28,7 @@ const {
|
|
|
27
28
|
lang = "en",
|
|
28
29
|
locale,
|
|
29
30
|
modifiedTime,
|
|
31
|
+
noindex,
|
|
30
32
|
publishedTime,
|
|
31
33
|
rssHref,
|
|
32
34
|
siteName,
|
|
@@ -35,6 +37,9 @@ const {
|
|
|
35
37
|
type = "website",
|
|
36
38
|
} = Astro.props;
|
|
37
39
|
|
|
40
|
+
const SKIP_LABELS = new Map([["pt", "Pular para o conteúdo"]]);
|
|
41
|
+
const skipLabel = SKIP_LABELS.get(lang.split("-")[0] ?? "") ?? "Skip to content";
|
|
42
|
+
|
|
38
43
|
const colorSchemeScript =
|
|
39
44
|
colorScheme === "auto"
|
|
40
45
|
? `(() => {
|
|
@@ -59,10 +64,12 @@ const colorSchemeScript =
|
|
|
59
64
|
image={image}
|
|
60
65
|
type={type}
|
|
61
66
|
siteName={siteName ?? siteTitle}
|
|
67
|
+
siteTitle={siteTitle}
|
|
62
68
|
locale={locale}
|
|
63
69
|
publishedTime={publishedTime}
|
|
64
70
|
modifiedTime={modifiedTime}
|
|
65
71
|
authorName={authorName}
|
|
72
|
+
noindex={noindex}
|
|
66
73
|
/>
|
|
67
74
|
{rssHref && (
|
|
68
75
|
<link rel="alternate" type="application/rss+xml" title={siteTitle ?? title} href={rssHref} />
|
|
@@ -83,7 +90,7 @@ const colorSchemeScript =
|
|
|
83
90
|
href="#main-content"
|
|
84
91
|
class="bg-background text-foreground border-border focus:ring-ring sr-only z-50 rounded-md border px-3 py-2 focus:not-sr-only focus:fixed focus:top-2 focus:left-2 focus:ring-2"
|
|
85
92
|
>
|
|
86
|
-
|
|
93
|
+
{skipLabel}
|
|
87
94
|
</a>
|
|
88
95
|
<slot name="header" />
|
|
89
96
|
<div class="flex-1">
|
|
@@ -38,16 +38,16 @@ const {
|
|
|
38
38
|
<Fragment slot="header">{siteTitle && <Header siteTitle={siteTitle} />}</Fragment>
|
|
39
39
|
<Fragment slot="footer">{siteTitle && <Footer siteTitle={siteTitle} />}</Fragment>
|
|
40
40
|
<main id="main-content" tabindex="-1" class="mx-auto w-full max-w-5xl px-5 py-12 sm:px-8 sm:py-20">
|
|
41
|
-
<header class="mb-12 grid gap-8 border-b border-border pb-12 md:grid-cols-
|
|
41
|
+
<header class="mb-12 grid gap-8 border-b border-border pb-12 md:grid-cols-list-heading md:items-end">
|
|
42
42
|
<div>
|
|
43
43
|
{eyebrow && (
|
|
44
|
-
<p class="text-muted-foreground font-mono text-xs font-semibold tracking-
|
|
44
|
+
<p class="text-muted-foreground font-mono text-xs font-semibold tracking-(--tracking-eyebrow)">{eyebrow}</p>
|
|
45
45
|
)}
|
|
46
|
-
<h1 class="text-foreground mt-4 max-w-
|
|
46
|
+
<h1 class="text-foreground mt-4 max-w-(--measure-list-title) font-serif text-5xl leading-(--leading-list-title) font-semibold tracking-(--tracking-list-title) sm:text-7xl">
|
|
47
47
|
{title}
|
|
48
48
|
</h1>
|
|
49
49
|
</div>
|
|
50
|
-
<p class="text-muted-foreground max-w-
|
|
50
|
+
<p class="text-muted-foreground max-w-(--measure-list-intro) text-lg text-pretty md:justify-self-end">{description}</p>
|
|
51
51
|
</header>
|
|
52
52
|
<section class="post-grid">
|
|
53
53
|
{posts.map((post) => <PostCard post={post} locale={locale} postHref={postHref} />)}
|
|
@@ -61,41 +61,3 @@ const {
|
|
|
61
61
|
)}
|
|
62
62
|
</main>
|
|
63
63
|
</BaseLayout>
|
|
64
|
-
|
|
65
|
-
<style is:global>
|
|
66
|
-
.post-grid {
|
|
67
|
-
display: grid;
|
|
68
|
-
gap: 1px;
|
|
69
|
-
overflow: hidden;
|
|
70
|
-
border: 1px solid var(--color-border);
|
|
71
|
-
background: var(--color-border);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
.post-grid .post-card {
|
|
75
|
-
border: 0;
|
|
76
|
-
background: var(--color-background);
|
|
77
|
-
padding: 1.75rem;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
.post-grid .post-card:first-child h2 {
|
|
81
|
-
max-width: 18ch;
|
|
82
|
-
font-family: var(--font-serif);
|
|
83
|
-
font-size: clamp(1.75rem, 4vw, 2.75rem);
|
|
84
|
-
line-height: 1.05;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
@media (min-width: 48rem) {
|
|
88
|
-
.post-grid {
|
|
89
|
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
.post-grid .post-card:first-child {
|
|
93
|
-
grid-column: 1 / -1;
|
|
94
|
-
min-height: 18rem;
|
|
95
|
-
display: flex;
|
|
96
|
-
flex-direction: column;
|
|
97
|
-
justify-content: end;
|
|
98
|
-
padding: 3rem;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
</style>
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { createCategoryIndex } from "./category-index";
|
|
4
|
+
|
|
5
|
+
const map = {
|
|
6
|
+
Architecture: "arquitetura",
|
|
7
|
+
"Art & Design": "arte-design",
|
|
8
|
+
"Arte & Design": "arte-design",
|
|
9
|
+
};
|
|
10
|
+
const DEFAULT = "sem-categoria";
|
|
11
|
+
|
|
12
|
+
const post = (id: string, categories: Array<string>, canonical?: string, day = 1) => ({
|
|
13
|
+
data: {
|
|
14
|
+
categories,
|
|
15
|
+
pubDate: new Date(Date.UTC(2026, 0, day)),
|
|
16
|
+
seo: { canonical_url: canonical },
|
|
17
|
+
},
|
|
18
|
+
id,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("createCategoryIndex", () => {
|
|
22
|
+
it("names a slug shared by a mapped and a native category after the native one", () => {
|
|
23
|
+
const { categoryName, groupByCategory } = createCategoryIndex(map, DEFAULT, [
|
|
24
|
+
post("a", ["Architecture"]),
|
|
25
|
+
post("b", ["Architecture"]),
|
|
26
|
+
post("c", ["Arquitetura"]),
|
|
27
|
+
]);
|
|
28
|
+
expect(categoryName("arquitetura")).toBe("Arquitetura");
|
|
29
|
+
expect(groupByCategory()).toEqual([
|
|
30
|
+
{ name: "Arquitetura", posts: expect.any(Array), slug: "arquitetura" },
|
|
31
|
+
]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("treats a name that slugifies to the slug once decoded as native", () => {
|
|
35
|
+
const { categoryName } = createCategoryIndex(map, DEFAULT, [
|
|
36
|
+
post("a", ["Art & Design"]),
|
|
37
|
+
post("b", ["Arte & Design"]),
|
|
38
|
+
]);
|
|
39
|
+
expect(categoryName("arte-design")).toBe("Arte & Design");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("falls back to the first name when no native name reaches the slug", () => {
|
|
43
|
+
const { categoryName } = createCategoryIndex(map, DEFAULT, [
|
|
44
|
+
post("a", ["Architecture"]),
|
|
45
|
+
post("b", ["Architecture"]),
|
|
46
|
+
]);
|
|
47
|
+
expect(categoryName("arquitetura")).toBe("Architecture");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("resolves a post's eyebrow through the same name as its category page", () => {
|
|
51
|
+
const { postCategory } = createCategoryIndex(map, DEFAULT, [
|
|
52
|
+
post("a", ["Business"], "https://x.com/arquitetura/a/"),
|
|
53
|
+
post("b", ["Architecture"], "https://x.com/arquitetura/b/"),
|
|
54
|
+
post("c", ["Arquitetura"]),
|
|
55
|
+
]);
|
|
56
|
+
expect(postCategory(post("a", ["Business"], "https://x.com/arquitetura/a/"))).toEqual({
|
|
57
|
+
name: "Arquitetura",
|
|
58
|
+
slug: "arquitetura",
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("uses a post's first category for a slug only its canonical url names", () => {
|
|
63
|
+
const { categoryName } = createCategoryIndex(map, DEFAULT, [
|
|
64
|
+
post("a", ["Locação de Espaços"], "https://x.com/aluguel-de-espacos/a/"),
|
|
65
|
+
]);
|
|
66
|
+
expect(categoryName("aluguel-de-espacos")).toBe("Locação de Espaços");
|
|
67
|
+
expect(categoryName("locacao-de-espacos")).toBe("Locação de Espaços");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("humanizes a slug no post names", () => {
|
|
71
|
+
const { categoryName, postCategory } = createCategoryIndex(map, DEFAULT, []);
|
|
72
|
+
expect(categoryName("sem-categoria")).toBe("Sem categoria");
|
|
73
|
+
expect(postCategory(post("orphan", []))).toEqual({ name: "Sem categoria", slug: DEFAULT });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("groups by the canonical category and sorts each bucket newest first", () => {
|
|
77
|
+
const { groupByCategory } = createCategoryIndex(map, DEFAULT, [
|
|
78
|
+
post("old", ["Arquitetura"], undefined, 1),
|
|
79
|
+
post("new", ["Arquitetura"], undefined, 9),
|
|
80
|
+
post("orphan", []),
|
|
81
|
+
]);
|
|
82
|
+
const buckets = groupByCategory();
|
|
83
|
+
expect(buckets.map((b) => b.slug)).toEqual(["arquitetura", "sem-categoria"]);
|
|
84
|
+
expect(buckets[0]?.posts.map((p) => p.id)).toEqual(["new", "old"]);
|
|
85
|
+
expect(buckets[1]?.name).toBe("Sem categoria");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("adds a post to each secondary category bucket only when asked", () => {
|
|
89
|
+
const posts = [post("a", ["Arquitetura", "Interiores"])];
|
|
90
|
+
const { groupByCategory } = createCategoryIndex(map, DEFAULT, posts);
|
|
91
|
+
expect(groupByCategory().map((b) => b.slug)).toEqual(["arquitetura"]);
|
|
92
|
+
expect(
|
|
93
|
+
groupByCategory({ includeSecondaryCategories: true }).map((b) => [b.slug, b.name]),
|
|
94
|
+
).toEqual([
|
|
95
|
+
["arquitetura", "Arquitetura"],
|
|
96
|
+
["interiores", "Interiores"],
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { byPubDateDesc } from "../content/index";
|
|
2
|
+
import { categoryLabel } from "./category-label";
|
|
3
|
+
import type { PostLike } from "./post-url";
|
|
4
|
+
import { createPostUrl } from "./post-url";
|
|
5
|
+
import { slugify } from "./slugify";
|
|
6
|
+
|
|
7
|
+
type CategoryPostLike = PostLike & { data: { categories: Array<string>; pubDate: Date } };
|
|
8
|
+
|
|
9
|
+
type CategoryBucket<P> = { name: string; posts: Array<P>; slug: string };
|
|
10
|
+
|
|
11
|
+
type PostCategory = { name: string; slug: string };
|
|
12
|
+
|
|
13
|
+
type GroupOptions = {
|
|
14
|
+
includeSecondaryCategories?: boolean;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const humanize = (slug: string): string => {
|
|
18
|
+
const words = slug.replaceAll("-", " ");
|
|
19
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const createCategoryIndex = <P extends CategoryPostLike>(
|
|
23
|
+
categorySlugMap: Record<string, string>,
|
|
24
|
+
defaultCategory: string,
|
|
25
|
+
posts: Array<P>,
|
|
26
|
+
) => {
|
|
27
|
+
const { postParams } = createPostUrl(categorySlugMap, defaultCategory);
|
|
28
|
+
const categorySlug = (name: string) => categorySlugMap[name] ?? slugify(name);
|
|
29
|
+
|
|
30
|
+
const namesBySlug = new Map<string, Set<string>>();
|
|
31
|
+
const register = (slug: string, name: string) => {
|
|
32
|
+
namesBySlug.set(slug, (namesBySlug.get(slug) ?? new Set()).add(name));
|
|
33
|
+
};
|
|
34
|
+
for (const post of posts) {
|
|
35
|
+
const { category } = postParams(post);
|
|
36
|
+
const names = post.data.categories;
|
|
37
|
+
for (const name of names) {
|
|
38
|
+
register(categorySlug(name), name);
|
|
39
|
+
}
|
|
40
|
+
const first = names[0];
|
|
41
|
+
if (first !== undefined && !names.some((name) => categorySlug(name) === category)) {
|
|
42
|
+
register(category, first);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const categoryName = (slug: string): string => {
|
|
47
|
+
const names = [...(namesBySlug.get(slug) ?? [])];
|
|
48
|
+
const native = names.find((name) => slugify(categoryLabel(name)) === slug);
|
|
49
|
+
const mapped = names.find((name) => categorySlug(name) === slug);
|
|
50
|
+
const name = native ?? mapped ?? names[0];
|
|
51
|
+
return name === undefined ? humanize(slug) : categoryLabel(name);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const postCategory = (post: PostLike): PostCategory => {
|
|
55
|
+
const { category } = postParams(post);
|
|
56
|
+
return { name: categoryName(category), slug: category };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const groupByCategory = ({ includeSecondaryCategories = false }: GroupOptions = {}): Array<
|
|
60
|
+
CategoryBucket<P>
|
|
61
|
+
> => {
|
|
62
|
+
const postsBySlug = new Map<string, Array<P>>();
|
|
63
|
+
const add = (slug: string, post: P) => {
|
|
64
|
+
const bucket = postsBySlug.get(slug);
|
|
65
|
+
if (bucket) {
|
|
66
|
+
bucket.push(post);
|
|
67
|
+
} else {
|
|
68
|
+
postsBySlug.set(slug, [post]);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (const post of posts) {
|
|
73
|
+
const { category } = postParams(post);
|
|
74
|
+
add(category, post);
|
|
75
|
+
if (!includeSecondaryCategories) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const name of post.data.categories) {
|
|
79
|
+
const slug = categorySlug(name);
|
|
80
|
+
if (slug !== category) {
|
|
81
|
+
add(slug, post);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return [...postsBySlug].map(([slug, bucketPosts]) => ({
|
|
87
|
+
name: categoryName(slug),
|
|
88
|
+
posts: bucketPosts.toSorted(byPubDateDesc),
|
|
89
|
+
slug,
|
|
90
|
+
}));
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return { categoryName, groupByCategory, postCategory };
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type { CategoryBucket, CategoryPostLike, PostCategory };
|
|
97
|
+
export { createCategoryIndex };
|
package/src/lib/cn.test.ts
CHANGED
|
@@ -4,12 +4,12 @@ import { cn } from "./cn";
|
|
|
4
4
|
|
|
5
5
|
describe("cn", () => {
|
|
6
6
|
it("merges class names", () => {
|
|
7
|
-
expect(cn("
|
|
7
|
+
expect(cn("block", "relative")).toBe("block relative");
|
|
8
8
|
});
|
|
9
9
|
it("dedupes conflicting tailwind classes (last wins)", () => {
|
|
10
10
|
expect(cn("p-2", "p-4")).toBe("p-4");
|
|
11
11
|
});
|
|
12
12
|
it("filters falsy values", () => {
|
|
13
|
-
expect(cn("
|
|
13
|
+
expect(cn("block", false, undefined, null, "relative")).toBe("block relative");
|
|
14
14
|
});
|
|
15
15
|
});
|
package/src/lib/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export { cn } from "./cn";
|
|
2
|
+
export type { CategoryBucket, CategoryPostLike, PostCategory } from "./category-index";
|
|
3
|
+
export { createCategoryIndex } from "./category-index";
|
|
2
4
|
export { categoryLabel } from "./category-label";
|
|
3
5
|
export { formatDate } from "./format-date";
|
|
4
6
|
export { getRelatedPosts } from "./get-related-posts";
|
|
@@ -7,4 +9,4 @@ export { createPostUrl } from "./post-url";
|
|
|
7
9
|
export { readingTime } from "./reading-time";
|
|
8
10
|
export { requireEnv } from "./require-env";
|
|
9
11
|
export { slugify } from "./slugify";
|
|
10
|
-
export { DEFAULT_TAG_BASE, tagUrl } from "./tag-url";
|
|
12
|
+
export { DEFAULT_TAG_BASE, tagLabel, tagUrl, uniqueTags } from "./tag-url";
|
package/src/lib/tag-url.test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
|
-
import { DEFAULT_TAG_BASE, tagUrl } from "./tag-url";
|
|
3
|
+
import { DEFAULT_TAG_BASE, tagLabel, tagUrl, uniqueTags } from "./tag-url";
|
|
4
4
|
|
|
5
5
|
describe("tagUrl", () => {
|
|
6
6
|
it("defaults to the /tag base every consuming blog routes on", () => {
|
|
@@ -21,3 +21,34 @@ describe("tagUrl", () => {
|
|
|
21
21
|
expect(tagUrl("Área Externa")).toBe("/tag/area-externa/");
|
|
22
22
|
});
|
|
23
23
|
});
|
|
24
|
+
|
|
25
|
+
describe("tagLabel", () => {
|
|
26
|
+
it("drops a leading hashtag and nothing else", () => {
|
|
27
|
+
expect(tagLabel("#construção")).toBe("construção");
|
|
28
|
+
expect(tagLabel("construção")).toBe("construção");
|
|
29
|
+
expect(tagLabel("c#")).toBe("c#");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("uniqueTags", () => {
|
|
34
|
+
it("collapses tags that share a slug onto the first occurrence", () => {
|
|
35
|
+
expect(
|
|
36
|
+
uniqueTags([
|
|
37
|
+
"#construção",
|
|
38
|
+
"construção",
|
|
39
|
+
"#LocaçõesDeEspaços",
|
|
40
|
+
"#locaçõesdeespaços",
|
|
41
|
+
"Tendências2025",
|
|
42
|
+
"#tendências2025",
|
|
43
|
+
"decoração",
|
|
44
|
+
]),
|
|
45
|
+
).toEqual(["#construção", "#LocaçõesDeEspaços", "Tendências2025", "decoração"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("keeps tags whose slugs differ", () => {
|
|
49
|
+
expect(uniqueTags(["arquitetura", "arquitetura de luxo"])).toEqual([
|
|
50
|
+
"arquitetura",
|
|
51
|
+
"arquitetura de luxo",
|
|
52
|
+
]);
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/lib/tag-url.ts
CHANGED
|
@@ -5,4 +5,18 @@ const DEFAULT_TAG_BASE = "/tag";
|
|
|
5
5
|
const tagUrl = (tag: string, base: string = DEFAULT_TAG_BASE): string =>
|
|
6
6
|
`${base.replace(/\/+$/v, "")}/${slugify(tag)}/`;
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
const tagLabel = (tag: string): string => tag.replace(/^#/v, "");
|
|
9
|
+
|
|
10
|
+
const uniqueTags = (tags: Array<string>): Array<string> => {
|
|
11
|
+
const seen = new Set<string>();
|
|
12
|
+
return tags.filter((tag) => {
|
|
13
|
+
const slug = slugify(tag);
|
|
14
|
+
if (seen.has(slug)) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
seen.add(slug);
|
|
18
|
+
return true;
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export { DEFAULT_TAG_BASE, tagLabel, tagUrl, uniqueTags };
|
package/src/styles/globals.css
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
It contributes tokens, base styles, prose, and the dark variant. */
|
|
3
3
|
|
|
4
4
|
@import "tw-animate-css";
|
|
5
|
+
@import "shadcn/tailwind.css";
|
|
5
6
|
|
|
6
7
|
/* Scan our own .astro/.tsx files so consumer Tailwind generates utilities
|
|
7
8
|
used by the theme. Paths resolve from dist/styles/globals.css after build:
|
|
@@ -9,10 +10,17 @@
|
|
|
9
10
|
@source "../../src/astro/**/*.astro";
|
|
10
11
|
@source "../../src/layouts/**/*.astro";
|
|
11
12
|
@source "../../src/components/**/*.{ts,tsx}";
|
|
13
|
+
@source "../../src/compositions/**/*.{ts,tsx}";
|
|
12
14
|
|
|
13
15
|
@custom-variant dark (&:is(.dark *));
|
|
14
16
|
|
|
15
17
|
@theme {
|
|
18
|
+
--tracking-eyebrow: 0.16em;
|
|
19
|
+
--tracking-list-title: -0.045em;
|
|
20
|
+
--leading-list-title: 0.95;
|
|
21
|
+
--measure-list-title: 14ch;
|
|
22
|
+
--measure-list-intro: 38ch;
|
|
23
|
+
|
|
16
24
|
--color-background: oklch(1 0 0);
|
|
17
25
|
--color-foreground: oklch(0.145 0 0);
|
|
18
26
|
--color-card: oklch(1 0 0);
|
|
@@ -24,7 +32,7 @@
|
|
|
24
32
|
--color-secondary: oklch(0.97 0 0);
|
|
25
33
|
--color-secondary-foreground: oklch(0.205 0 0);
|
|
26
34
|
--color-muted: oklch(0.97 0 0);
|
|
27
|
-
--color-muted-foreground: oklch(0.
|
|
35
|
+
--color-muted-foreground: oklch(0.54 0 0);
|
|
28
36
|
--color-accent: oklch(0.97 0 0);
|
|
29
37
|
--color-accent-foreground: oklch(0.205 0 0);
|
|
30
38
|
--color-destructive: oklch(0.577 0.245 27.325);
|
|
@@ -264,3 +272,58 @@
|
|
|
264
272
|
text-align: left;
|
|
265
273
|
}
|
|
266
274
|
}
|
|
275
|
+
|
|
276
|
+
@utility post-grid {
|
|
277
|
+
display: grid;
|
|
278
|
+
gap: 1px;
|
|
279
|
+
overflow: hidden;
|
|
280
|
+
border: 1px solid var(--color-border);
|
|
281
|
+
background: var(--color-border);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.post-grid .post-card {
|
|
285
|
+
border: 0;
|
|
286
|
+
background: var(--color-background);
|
|
287
|
+
padding: 1.75rem;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.post-grid .post-card:first-child h2 {
|
|
291
|
+
max-width: 18ch;
|
|
292
|
+
font-family: var(--font-serif);
|
|
293
|
+
font-size: clamp(1.75rem, 4vw, 2.75rem);
|
|
294
|
+
line-height: 1.05;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
@media (min-width: 48rem) {
|
|
298
|
+
.post-grid {
|
|
299
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.post-grid .post-card:first-child {
|
|
303
|
+
grid-column: 1 / -1;
|
|
304
|
+
min-height: 18rem;
|
|
305
|
+
display: flex;
|
|
306
|
+
flex-direction: column;
|
|
307
|
+
justify-content: end;
|
|
308
|
+
padding: 3rem;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/* Keep first 3 eager so above-the-fold paint isn't gated on containment checks. */
|
|
313
|
+
@utility post-card {
|
|
314
|
+
&:nth-child(n + 4) {
|
|
315
|
+
content-visibility: auto;
|
|
316
|
+
contain-intrinsic-size: auto 200px;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
@utility grid-cols-list-heading {
|
|
321
|
+
grid-template-columns: 1.2fr 0.8fr;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
@theme inline {
|
|
325
|
+
--radius-control-sm: min(var(--radius-md), 10px);
|
|
326
|
+
--radius-control: min(var(--radius-md), 12px);
|
|
327
|
+
--text-control: 0.8rem;
|
|
328
|
+
--color-secondary-hover: color-mix(in oklch, var(--color-secondary), var(--color-foreground) 5%);
|
|
329
|
+
}
|