stack-site-builder 1.10.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/LICENSE +21 -0
- package/README.md +45 -0
- package/dev/local-samples-client.js +284 -0
- package/dev/local-samples.mjs +594 -0
- package/index.d.ts +12 -0
- package/index.mjs +124 -0
- package/markdown.mjs +394 -0
- package/package.json +52 -0
- package/src/components/ArrowUpRight.astro +24 -0
- package/src/components/ArticleCard.astro +56 -0
- package/src/components/ArticleDetail.astro +100 -0
- package/src/components/ArticleLink.astro +45 -0
- package/src/components/BackToTop.astro +47 -0
- package/src/components/BlogIndex.astro +137 -0
- package/src/components/Breadcrumb.astro +37 -0
- package/src/components/CategoryIndex.astro +115 -0
- package/src/components/ChevronDown.astro +23 -0
- package/src/components/CodeSamples.astro +141 -0
- package/src/components/CollapsibleGrid.astro +50 -0
- package/src/components/ConceptCard.astro +59 -0
- package/src/components/ConceptDetail.astro +102 -0
- package/src/components/ConceptIndex.astro +143 -0
- package/src/components/ConceptLink.astro +43 -0
- package/src/components/ConceptTools.astro +49 -0
- package/src/components/DeckView.astro +772 -0
- package/src/components/DetailTabs.astro +219 -0
- package/src/components/FilterControls.astro +407 -0
- package/src/components/Glossary.astro +264 -0
- package/src/components/Home.astro +90 -0
- package/src/components/LanguageSwitcher.astro +86 -0
- package/src/components/ListControls.astro +182 -0
- package/src/components/MermaidLoader.astro +96 -0
- package/src/components/PricingSection.astro +100 -0
- package/src/components/ProjectFileList.astro +68 -0
- package/src/components/ProjectFileTree.astro +95 -0
- package/src/components/ProjectRelatedTools.astro +70 -0
- package/src/components/ProjectViewer.astro +431 -0
- package/src/components/RelatedArticles.astro +52 -0
- package/src/components/RelatedConcepts.astro +60 -0
- package/src/components/RelatedTools.astro +55 -0
- package/src/components/SampleIndex.astro +464 -0
- package/src/components/SamplePage.astro +95 -0
- package/src/components/SampleProject.astro +56 -0
- package/src/components/Slide.astro +36 -0
- package/src/components/SlidesIndex.astro +60 -0
- package/src/components/StackCard.astro +183 -0
- package/src/components/StackDetail.astro +507 -0
- package/src/components/StarIcon.astro +21 -0
- package/src/components/TagIndex.astro +51 -0
- package/src/components/ThemeToggle.astro +106 -0
- package/src/components/Toc.astro +39 -0
- package/src/components/TocRail.astro +74 -0
- package/src/components/ToolAvatar.astro +133 -0
- package/src/components/ToolMeta.astro +95 -0
- package/src/components/VendorIndex.astro +51 -0
- package/src/content.ts +228 -0
- package/src/i18n/ui.ts +337 -0
- package/src/layouts/BaseLayout.astro +287 -0
- package/src/lib/articles.ts +27 -0
- package/src/lib/assets.ts +19 -0
- package/src/lib/category-tree.ts +64 -0
- package/src/lib/concepts.ts +35 -0
- package/src/lib/dates.ts +21 -0
- package/src/lib/facets.ts +57 -0
- package/src/lib/github.ts +186 -0
- package/src/lib/inline-md.ts +30 -0
- package/src/lib/pricing.ts +22 -0
- package/src/lib/project.ts +309 -0
- package/src/lib/samples.ts +88 -0
- package/src/lib/slides.ts +25 -0
- package/src/lib/stacks.ts +129 -0
- package/src/lib/toc-rail-client.ts +79 -0
- package/src/pages/article/[...id].astro +24 -0
- package/src/pages/article/category/[id].astro +17 -0
- package/src/pages/article/index.astro +12 -0
- package/src/pages/categories/[id].astro +25 -0
- package/src/pages/concept/[...id].astro +24 -0
- package/src/pages/concept/category/[id].astro +17 -0
- package/src/pages/concept/index.astro +12 -0
- package/src/pages/glossary.astro +12 -0
- package/src/pages/index.astro +9 -0
- package/src/pages/ko/article/[...id].astro +24 -0
- package/src/pages/ko/article/category/[id].astro +17 -0
- package/src/pages/ko/article/index.astro +12 -0
- package/src/pages/ko/categories/[id].astro +24 -0
- package/src/pages/ko/concept/[...id].astro +24 -0
- package/src/pages/ko/concept/category/[id].astro +17 -0
- package/src/pages/ko/concept/index.astro +12 -0
- package/src/pages/ko/glossary.astro +12 -0
- package/src/pages/ko/index.astro +9 -0
- package/src/pages/ko/sample/[folder].astro +16 -0
- package/src/pages/ko/sample/index.astro +12 -0
- package/src/pages/ko/slides/[deck].astro +14 -0
- package/src/pages/ko/slides/index.astro +12 -0
- package/src/pages/ko/stack/[...id].astro +51 -0
- package/src/pages/ko/tags/[tag].astro +18 -0
- package/src/pages/ko/vendors/[vendor].astro +18 -0
- package/src/pages/sample/[folder].astro +16 -0
- package/src/pages/sample/index.astro +12 -0
- package/src/pages/slides/[deck].astro +14 -0
- package/src/pages/slides/index.astro +12 -0
- package/src/pages/stack/[...id].astro +51 -0
- package/src/pages/tags/[tag].astro +18 -0
- package/src/pages/vendors/[vendor].astro +18 -0
- package/src/styles/global.css +2350 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
overviewLabel: string;
|
|
4
|
+
pricingLabel?: string;
|
|
5
|
+
codeLabel?: string;
|
|
6
|
+
implLabel?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { overviewLabel, pricingLabel, codeLabel, implLabel } = Astro.props;
|
|
10
|
+
const tabClass =
|
|
11
|
+
'aas-tab cursor-pointer rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--aas-muted)] hover:text-[var(--aas-text)]';
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
<div data-tabs>
|
|
15
|
+
<div role="tablist" class="inline-flex gap-1 rounded-xl bg-[var(--aas-tint)] p-1">
|
|
16
|
+
<button type="button" role="tab" id="tab-overview" aria-controls="panel-overview" data-tab="overview" class={tabClass} aria-selected="true">
|
|
17
|
+
{overviewLabel}
|
|
18
|
+
</button>
|
|
19
|
+
{
|
|
20
|
+
pricingLabel && (
|
|
21
|
+
<button type="button" role="tab" id="tab-pricing" aria-controls="panel-pricing" data-tab="pricing" class={tabClass} aria-selected="false">
|
|
22
|
+
{pricingLabel}
|
|
23
|
+
</button>
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
{
|
|
27
|
+
codeLabel && (
|
|
28
|
+
<button type="button" role="tab" id="tab-code" aria-controls="panel-code" data-tab="code" class={tabClass} aria-selected="false">
|
|
29
|
+
{codeLabel}
|
|
30
|
+
</button>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
{
|
|
34
|
+
implLabel && (
|
|
35
|
+
<button type="button" role="tab" id="tab-impl" aria-controls="panel-impl" data-tab="impl" class={tabClass} aria-selected="false">
|
|
36
|
+
{implLabel}
|
|
37
|
+
</button>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<div role="tabpanel" id="panel-overview" aria-labelledby="tab-overview" data-panel="overview" class="mt-6">
|
|
43
|
+
<slot name="overview" />
|
|
44
|
+
</div>
|
|
45
|
+
{
|
|
46
|
+
pricingLabel && (
|
|
47
|
+
<div role="tabpanel" id="panel-pricing" aria-labelledby="tab-pricing" data-panel="pricing" class="mt-6 hidden">
|
|
48
|
+
<slot name="pricing" />
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
{
|
|
53
|
+
codeLabel && (
|
|
54
|
+
<div role="tabpanel" id="panel-code" aria-labelledby="tab-code" data-panel="code" class="mt-6 hidden">
|
|
55
|
+
<slot name="code" />
|
|
56
|
+
</div>
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
{
|
|
60
|
+
implLabel && (
|
|
61
|
+
<div role="tabpanel" id="panel-impl" aria-labelledby="tab-impl" data-panel="impl" class="mt-6 hidden">
|
|
62
|
+
<slot name="impl" />
|
|
63
|
+
</div>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
{/* Pre-paint: pick the tab from ?tab= (or a legacy hash / the tab-owned
|
|
68
|
+
params) using inline display styles, which apply before the stylesheet
|
|
69
|
+
loads (so it works in dev too). */}
|
|
70
|
+
<script is:inline>
|
|
71
|
+
(function () {
|
|
72
|
+
var root = document.currentScript.parentElement;
|
|
73
|
+
var hash = decodeURIComponent(location.hash.slice(1));
|
|
74
|
+
var params = new URLSearchParams(location.search);
|
|
75
|
+
var has = function (id) {
|
|
76
|
+
var found = false;
|
|
77
|
+
root.querySelectorAll('[data-tab]').forEach(function (t) {
|
|
78
|
+
if (t.dataset.tab === id) found = true;
|
|
79
|
+
});
|
|
80
|
+
return found;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
var target = null;
|
|
84
|
+
var pt = params.get('tab');
|
|
85
|
+
if (pt && has(pt)) target = pt;
|
|
86
|
+
if (!target && hash && hash !== 'overview') {
|
|
87
|
+
if (has(hash)) target = hash; // legacy #<tabid>
|
|
88
|
+
if (!target) {
|
|
89
|
+
root.querySelectorAll('[data-panel]').forEach(function (p) {
|
|
90
|
+
if (target) return;
|
|
91
|
+
try {
|
|
92
|
+
var esc = window.CSS && CSS.escape ? CSS.escape(hash) : hash;
|
|
93
|
+
if (p.querySelector('#' + esc)) target = p.dataset.panel;
|
|
94
|
+
} catch (e) {}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!target) {
|
|
99
|
+
// Tabs own their params: ?ex/?file → impl, ?v → code.
|
|
100
|
+
if ((params.has('ex') || params.has('file')) && has('impl')) target = 'impl';
|
|
101
|
+
else if (params.has('v') && has('code')) target = 'code';
|
|
102
|
+
}
|
|
103
|
+
if (!target || target === 'overview') return;
|
|
104
|
+
root.querySelectorAll('[data-panel]').forEach(function (p) {
|
|
105
|
+
p.style.display = p.dataset.panel === target ? 'block' : 'none';
|
|
106
|
+
});
|
|
107
|
+
root.querySelectorAll('[data-tab]').forEach(function (t) {
|
|
108
|
+
t.setAttribute('aria-selected', String(t.dataset.tab === target));
|
|
109
|
+
});
|
|
110
|
+
})();
|
|
111
|
+
</script>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<style>
|
|
115
|
+
.aas-tab[aria-selected='true'] {
|
|
116
|
+
background: var(--aas-panel);
|
|
117
|
+
color: var(--aas-text);
|
|
118
|
+
box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
|
|
119
|
+
}
|
|
120
|
+
</style>
|
|
121
|
+
|
|
122
|
+
<script>
|
|
123
|
+
// The selected tab lives in ?tab=; each tab also owns its own params, listed
|
|
124
|
+
// here so switching tabs can drop the others' (e.g. impl's ?ex/?file must not
|
|
125
|
+
// linger on the code tab). The hash is only ever a scroll anchor.
|
|
126
|
+
const TAB_PARAM = 'tab';
|
|
127
|
+
const OWNED: Record<string, string[]> = { code: ['v'], impl: ['ex', 'file'] };
|
|
128
|
+
|
|
129
|
+
function stripForeign(url: URL, id: string) {
|
|
130
|
+
for (const [tab, ps] of Object.entries(OWNED))
|
|
131
|
+
if (tab !== id) ps.forEach((p) => url.searchParams.delete(p));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
document.querySelectorAll<HTMLElement>('[data-tabs]').forEach((root) => {
|
|
135
|
+
const tabs = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-tab]'));
|
|
136
|
+
const panels = Array.from(root.querySelectorAll<HTMLElement>('[data-panel]'));
|
|
137
|
+
const tabIds = tabs.map((t) => t.dataset.tab);
|
|
138
|
+
|
|
139
|
+
function applyTab(id: string) {
|
|
140
|
+
tabs.forEach((t) => t.setAttribute('aria-selected', String(t.dataset.tab === id)));
|
|
141
|
+
panels.forEach((p) => {
|
|
142
|
+
p.style.removeProperty('display'); // clear the pre-paint inline style
|
|
143
|
+
p.classList.toggle('hidden', p.dataset.panel !== id);
|
|
144
|
+
});
|
|
145
|
+
// Defer the broadcast to a microtask so child components (ProjectViewer,
|
|
146
|
+
// CodeSamples) have registered their listeners and can reflect their own
|
|
147
|
+
// params into the URL — even for the initial tab.
|
|
148
|
+
queueMicrotask(() => {
|
|
149
|
+
document.dispatchEvent(new CustomEvent('aas:tabchange', { detail: { id } }));
|
|
150
|
+
(window as unknown as { __aasRenderMermaid?: () => void }).__aasRenderMermaid?.();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Clicking a tab: set ?tab=, drop other tabs' params, drop the scroll hash.
|
|
155
|
+
tabs.forEach((tab) =>
|
|
156
|
+
tab.addEventListener('click', () => {
|
|
157
|
+
const id = tab.dataset.tab!;
|
|
158
|
+
const url = new URL(location.href);
|
|
159
|
+
if (id === 'overview') url.searchParams.delete(TAB_PARAM);
|
|
160
|
+
else url.searchParams.set(TAB_PARAM, id);
|
|
161
|
+
stripForeign(url, id);
|
|
162
|
+
history.replaceState(null, '', url.pathname + url.search);
|
|
163
|
+
applyTab(id);
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// Resolve the initial tab: ?tab=, then a legacy #<tabid> / #<anchor>, then
|
|
168
|
+
// the tab-owned params (?ex/?file → impl, ?v → code). Else → Overview.
|
|
169
|
+
const raw = decodeURIComponent(location.hash.slice(1));
|
|
170
|
+
const params = new URLSearchParams(location.search);
|
|
171
|
+
let initial = 'overview';
|
|
172
|
+
const paramTab = params.get(TAB_PARAM);
|
|
173
|
+
if (paramTab && tabIds.includes(paramTab)) {
|
|
174
|
+
initial = paramTab;
|
|
175
|
+
} else if (tabIds.includes(raw)) {
|
|
176
|
+
initial = raw;
|
|
177
|
+
} else if (raw) {
|
|
178
|
+
for (const p of panels) {
|
|
179
|
+
const esc = window.CSS && CSS.escape ? CSS.escape(raw) : raw;
|
|
180
|
+
try {
|
|
181
|
+
if (p.querySelector('#' + esc)) {
|
|
182
|
+
initial = p.dataset.panel!;
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
/* invalid selector — ignore */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (initial === 'overview') {
|
|
191
|
+
if ((params.has('ex') || params.has('file')) && tabIds.includes('impl')) initial = 'impl';
|
|
192
|
+
else if (params.has('v') && tabIds.includes('code')) initial = 'code';
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
applyTab(initial);
|
|
196
|
+
|
|
197
|
+
// Normalize the URL to the ?tab= form (drop a legacy tab-only hash, strip
|
|
198
|
+
// params owned by other tabs) without adding a history entry.
|
|
199
|
+
{
|
|
200
|
+
const url = new URL(location.href);
|
|
201
|
+
if (initial === 'overview') url.searchParams.delete(TAB_PARAM);
|
|
202
|
+
else url.searchParams.set(TAB_PARAM, initial);
|
|
203
|
+
stripForeign(url, initial);
|
|
204
|
+
if (tabIds.includes(raw)) url.hash = ''; // hash was just the tab id
|
|
205
|
+
const next = url.pathname + url.search + url.hash;
|
|
206
|
+
if (next !== location.pathname + location.search + location.hash)
|
|
207
|
+
history.replaceState(null, '', next);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Deep-link scroll: select the sample for a #s<n>- hash (unless ?v= already
|
|
211
|
+
// chose one) and scroll the section into view.
|
|
212
|
+
if (raw && !tabIds.includes(raw)) {
|
|
213
|
+
const m = raw.match(/^s(\d+)-/);
|
|
214
|
+
if (m && !params.has('v'))
|
|
215
|
+
document.dispatchEvent(new CustomEvent('aas:select-sample', { detail: { index: Number(m[1]) } }));
|
|
216
|
+
document.getElementById(raw)?.scrollIntoView();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
</script>
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Shared search · filter · sort bar for the homepage and category pages.
|
|
3
|
+
// Filtering runs client-side over the page's [data-section]/[data-grid]/[data-tool]
|
|
4
|
+
// markup, and the full filter state is mirrored into the URL query string
|
|
5
|
+
// (?q, ?pricing, ?license, ?language, ?updated, ?sort) so a filtered view is
|
|
6
|
+
// shareable and reproducible. The parent supplies the data-derived facets.
|
|
7
|
+
import ChevronDown from './ChevronDown.astro';
|
|
8
|
+
import { useTranslations, type Lang } from '../i18n/ui';
|
|
9
|
+
|
|
10
|
+
interface Facet {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
options: { value: string; label: string }[];
|
|
14
|
+
}
|
|
15
|
+
interface Props {
|
|
16
|
+
lang: Lang;
|
|
17
|
+
facets: Facet[];
|
|
18
|
+
anchorId?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { lang, facets, anchorId } = Astro.props;
|
|
22
|
+
const t = useTranslations(lang);
|
|
23
|
+
|
|
24
|
+
const freshnessOptions = [
|
|
25
|
+
{ months: 0, label: t('updated.all') },
|
|
26
|
+
{ months: 6, label: t('updated.6m') },
|
|
27
|
+
{ months: 12, label: t('updated.1y') },
|
|
28
|
+
{ months: 24, label: t('updated.2y') },
|
|
29
|
+
];
|
|
30
|
+
const sortOptions = [
|
|
31
|
+
{ key: 'alpha', label: t('sort.alpha') },
|
|
32
|
+
{ key: 'recent', label: t('sort.recent') },
|
|
33
|
+
{ key: 'stars', label: t('sort.stars') },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const summaryClass =
|
|
37
|
+
'flex items-center gap-1.5 rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3 py-1.5 text-sm text-[var(--aas-text)]';
|
|
38
|
+
const menuClass =
|
|
39
|
+
'absolute left-0 z-10 mt-1 max-h-72 min-w-[12rem] overflow-y-auto rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] p-1 shadow-lg';
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
<script is:inline>
|
|
43
|
+
// Pre-paint: if the URL carries a filter/sort, hide the catalog (via
|
|
44
|
+
// [data-prefilter] on <html>) until the controls script below applies it, so
|
|
45
|
+
// the list never flashes unfiltered first. Runs synchronously during parse,
|
|
46
|
+
// before the controls + grids render. Cleared in that script's load sequence.
|
|
47
|
+
try {
|
|
48
|
+
if (/[?&](q|pricing|license|language|updated|sort)=/.test(location.search)) {
|
|
49
|
+
document.documentElement.setAttribute('data-prefilter', '');
|
|
50
|
+
// Safety net: never leave the catalog hidden if the module script fails to
|
|
51
|
+
// load/run (it clears this far sooner on a normal load).
|
|
52
|
+
setTimeout(function () {
|
|
53
|
+
document.documentElement.removeAttribute('data-prefilter');
|
|
54
|
+
}, 1500);
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {}
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<div id={anchorId} class="flex scroll-mt-6 flex-col gap-3" data-controls>
|
|
60
|
+
<input
|
|
61
|
+
type="search"
|
|
62
|
+
data-search-input
|
|
63
|
+
placeholder={t('search.placeholder')}
|
|
64
|
+
aria-label={t('search.placeholder')}
|
|
65
|
+
class="w-full rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] px-3.5 py-2.5 text-sm text-[var(--aas-text)] outline-none placeholder:text-[var(--aas-muted)] focus:border-[var(--aas-accent)]"
|
|
66
|
+
/>
|
|
67
|
+
|
|
68
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
69
|
+
{
|
|
70
|
+
facets.map((f) => (
|
|
71
|
+
<details class="aas-dd">
|
|
72
|
+
<summary class={summaryClass}>
|
|
73
|
+
{f.label}
|
|
74
|
+
<span data-facet-count={f.key} hidden class="aas-pricing rounded px-1.5 text-xs" />
|
|
75
|
+
<ChevronDown class="text-[var(--aas-muted)]" />
|
|
76
|
+
</summary>
|
|
77
|
+
<ul class={menuClass}>
|
|
78
|
+
{f.options.map((o) => (
|
|
79
|
+
<li>
|
|
80
|
+
<label class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-[var(--aas-tint)]">
|
|
81
|
+
<input type="checkbox" data-facet={f.key} value={o.value} class="aas-check" />
|
|
82
|
+
{o.label}
|
|
83
|
+
</label>
|
|
84
|
+
</li>
|
|
85
|
+
))}
|
|
86
|
+
</ul>
|
|
87
|
+
</details>
|
|
88
|
+
))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
{/* Last-updated freshness — single select; hides tools older than the pick. */}
|
|
92
|
+
<details class="aas-dd" data-fresh-dd>
|
|
93
|
+
<summary class={summaryClass}>
|
|
94
|
+
<span class="text-[var(--aas-muted)]">{t('filter.updated')}:</span>
|
|
95
|
+
<span data-fresh-label>{freshnessOptions[0].label}</span>
|
|
96
|
+
<ChevronDown class="text-[var(--aas-muted)]" />
|
|
97
|
+
</summary>
|
|
98
|
+
<ul class={menuClass}>
|
|
99
|
+
{
|
|
100
|
+
freshnessOptions.map((o, i) => (
|
|
101
|
+
<li>
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
data-fresh={o.months}
|
|
105
|
+
aria-current={i === 0 ? 'true' : 'false'}
|
|
106
|
+
class="flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-[var(--aas-tint)] aria-[current=true]:font-semibold aria-[current=true]:text-[var(--aas-accent)]"
|
|
107
|
+
>
|
|
108
|
+
{o.label}
|
|
109
|
+
<span data-check aria-hidden="true" class="opacity-0">
|
|
110
|
+
✓
|
|
111
|
+
</span>
|
|
112
|
+
</button>
|
|
113
|
+
</li>
|
|
114
|
+
))
|
|
115
|
+
}
|
|
116
|
+
</ul>
|
|
117
|
+
</details>
|
|
118
|
+
|
|
119
|
+
<details class="aas-dd sm:ml-auto">
|
|
120
|
+
<summary class={summaryClass}>
|
|
121
|
+
<span class="text-[var(--aas-muted)]">{t('sort.label')}:</span>
|
|
122
|
+
<span data-sort-label>{sortOptions[0].label}</span>
|
|
123
|
+
<ChevronDown class="text-[var(--aas-muted)]" />
|
|
124
|
+
</summary>
|
|
125
|
+
<ul class={menuClass}>
|
|
126
|
+
{
|
|
127
|
+
sortOptions.map((s, i) => (
|
|
128
|
+
<li>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
data-sort={s.key}
|
|
132
|
+
aria-current={i === 0 ? 'true' : 'false'}
|
|
133
|
+
class="flex w-full items-center justify-between gap-2 rounded px-2 py-1.5 text-left text-sm hover:bg-[var(--aas-tint)] aria-[current=true]:font-semibold aria-[current=true]:text-[var(--aas-accent)]"
|
|
134
|
+
>
|
|
135
|
+
{s.label}
|
|
136
|
+
<span data-check aria-hidden="true" class="opacity-0">
|
|
137
|
+
✓
|
|
138
|
+
</span>
|
|
139
|
+
</button>
|
|
140
|
+
</li>
|
|
141
|
+
))
|
|
142
|
+
}
|
|
143
|
+
</ul>
|
|
144
|
+
</details>
|
|
145
|
+
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
data-clear
|
|
149
|
+
hidden
|
|
150
|
+
class="rounded-lg px-2.5 py-1.5 text-sm text-[var(--aas-muted)] hover:text-[var(--aas-accent)]"
|
|
151
|
+
>
|
|
152
|
+
{t('filter.clear')}
|
|
153
|
+
</button>
|
|
154
|
+
|
|
155
|
+
{/* Density toggle: standard cards vs compact gallery (saved in localStorage) */}
|
|
156
|
+
<div
|
|
157
|
+
data-view-group
|
|
158
|
+
role="group"
|
|
159
|
+
aria-label={t('view.label')}
|
|
160
|
+
class="inline-flex items-center gap-0.5 rounded-lg border border-[var(--aas-border)] bg-[var(--aas-panel)] p-0.5"
|
|
161
|
+
>
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
data-view-btn="grid"
|
|
165
|
+
aria-label={t('view.standard')}
|
|
166
|
+
title={t('view.standard')}
|
|
167
|
+
class="aas-viewbtn rounded-md p-1.5 text-[var(--aas-muted)]"
|
|
168
|
+
>
|
|
169
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
170
|
+
<rect x="3" y="4" width="18" height="7" rx="1" />
|
|
171
|
+
<rect x="3" y="13" width="18" height="7" rx="1" />
|
|
172
|
+
</svg>
|
|
173
|
+
</button>
|
|
174
|
+
<button
|
|
175
|
+
type="button"
|
|
176
|
+
data-view-btn="gallery"
|
|
177
|
+
aria-label={t('view.gallery')}
|
|
178
|
+
title={t('view.gallery')}
|
|
179
|
+
class="aas-viewbtn rounded-md p-1.5 text-[var(--aas-muted)]"
|
|
180
|
+
>
|
|
181
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
182
|
+
<rect x="3" y="3" width="7" height="7" rx="1" />
|
|
183
|
+
<rect x="14" y="3" width="7" height="7" rx="1" />
|
|
184
|
+
<rect x="3" y="14" width="7" height="7" rx="1" />
|
|
185
|
+
<rect x="14" y="14" width="7" height="7" rx="1" />
|
|
186
|
+
</svg>
|
|
187
|
+
</button>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<p data-no-results hidden class="mt-10 text-center text-[var(--aas-muted)]">{t('results.none')}</p>
|
|
193
|
+
|
|
194
|
+
<script>
|
|
195
|
+
const root = document.querySelector<HTMLElement>('[data-controls]');
|
|
196
|
+
if (root) {
|
|
197
|
+
const input = root.querySelector<HTMLInputElement>('[data-search-input]')!;
|
|
198
|
+
const boxes = Array.from(root.querySelectorAll<HTMLInputElement>('input[data-facet]'));
|
|
199
|
+
const sortBtns = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-sort]'));
|
|
200
|
+
const sortLabel = root.querySelector<HTMLElement>('[data-sort-label]')!;
|
|
201
|
+
const freshBtns = Array.from(root.querySelectorAll<HTMLButtonElement>('[data-fresh]'));
|
|
202
|
+
const freshLabel = root.querySelector<HTMLElement>('[data-fresh-label]')!;
|
|
203
|
+
const clearBtn = root.querySelector<HTMLButtonElement>('[data-clear]')!;
|
|
204
|
+
const sections = Array.from(document.querySelectorAll<HTMLElement>('[data-section]'));
|
|
205
|
+
const noResults = document.querySelector<HTMLElement>('[data-no-results]')!;
|
|
206
|
+
const FACETS = ['pricing', 'license', 'language'];
|
|
207
|
+
|
|
208
|
+
// Carry the live filter/sort state across catalog navigation: category links
|
|
209
|
+
// (and the breadcrumb) keep the current query string, so drilling into a
|
|
210
|
+
// category preserves the sort/filters. Each link's filter-free href is
|
|
211
|
+
// remembered once, then rewritten with the current `location.search`.
|
|
212
|
+
const keepLinks = Array.from(
|
|
213
|
+
document.querySelectorAll<HTMLAnchorElement>(
|
|
214
|
+
'[data-keep-filters], nav[aria-label="Breadcrumb"] a',
|
|
215
|
+
),
|
|
216
|
+
);
|
|
217
|
+
keepLinks.forEach((a) => {
|
|
218
|
+
if (a.dataset.baseHref === undefined) a.dataset.baseHref = a.getAttribute('href') ?? '';
|
|
219
|
+
});
|
|
220
|
+
function syncLinks() {
|
|
221
|
+
for (const a of keepLinks) {
|
|
222
|
+
const [pathQ, hash] = (a.dataset.baseHref ?? '').split('#');
|
|
223
|
+
const path = pathQ.split('?')[0];
|
|
224
|
+
a.setAttribute('href', path + location.search + (hash ? `#${hash}` : ''));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
let sort = 'alpha';
|
|
228
|
+
let fresh = 0; // hide tools whose latest release is older than this many months (0 = off)
|
|
229
|
+
|
|
230
|
+
const alpha = (a: HTMLElement, b: HTMLElement) =>
|
|
231
|
+
(a.dataset.name ?? '').localeCompare(b.dataset.name ?? '');
|
|
232
|
+
|
|
233
|
+
function comparator(a: HTMLElement, b: HTMLElement) {
|
|
234
|
+
if (sort === 'recent') {
|
|
235
|
+
const ad = a.dataset.date ?? '';
|
|
236
|
+
const bd = b.dataset.date ?? '';
|
|
237
|
+
if (!ad && !bd) return alpha(a, b);
|
|
238
|
+
if (!ad) return 1;
|
|
239
|
+
if (!bd) return -1;
|
|
240
|
+
return bd.localeCompare(ad);
|
|
241
|
+
}
|
|
242
|
+
if (sort === 'stars') {
|
|
243
|
+
const ah = a.dataset.stars !== '';
|
|
244
|
+
const bh = b.dataset.stars !== '';
|
|
245
|
+
if (!ah && !bh) return alpha(a, b);
|
|
246
|
+
if (!ah) return -1; // no-star (commercial) first, alphabetically
|
|
247
|
+
if (!bh) return 1;
|
|
248
|
+
return Number(b.dataset.stars) - Number(a.dataset.stars);
|
|
249
|
+
}
|
|
250
|
+
return alpha(a, b);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function selected(facet: string) {
|
|
254
|
+
return new Set(boxes.filter((b) => b.dataset.facet === facet && b.checked).map((b) => b.value));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Mirror the live state into the URL query string (shareable / reproducible).
|
|
258
|
+
function writeUrl() {
|
|
259
|
+
const p = new URLSearchParams();
|
|
260
|
+
const q = input.value.trim();
|
|
261
|
+
if (q) p.set('q', q);
|
|
262
|
+
for (const key of FACETS) {
|
|
263
|
+
const vals = [...selected(key)];
|
|
264
|
+
if (vals.length) p.set(key, vals.join(','));
|
|
265
|
+
}
|
|
266
|
+
if (fresh) p.set('updated', String(fresh));
|
|
267
|
+
if (sort !== 'alpha') p.set('sort', sort);
|
|
268
|
+
const qs = p.toString();
|
|
269
|
+
history.replaceState(null, '', location.pathname + (qs ? `?${qs}` : '') + location.hash);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Restore state from the URL on load (without rewriting it).
|
|
273
|
+
function readUrl() {
|
|
274
|
+
const p = new URLSearchParams(location.search);
|
|
275
|
+
input.value = p.get('q') ?? '';
|
|
276
|
+
for (const key of FACETS) {
|
|
277
|
+
const vals = new Set((p.get(key) ?? '').split(',').filter(Boolean));
|
|
278
|
+
boxes.filter((b) => b.dataset.facet === key).forEach((b) => (b.checked = vals.has(b.value)));
|
|
279
|
+
}
|
|
280
|
+
const up = Number(p.get('updated') ?? '0');
|
|
281
|
+
const fb = freshBtns.find((b) => Number(b.dataset.fresh) === up) ?? freshBtns[0];
|
|
282
|
+
fresh = Number(fb.dataset.fresh);
|
|
283
|
+
freshLabel.textContent = fb.textContent!.replace('✓', '').trim();
|
|
284
|
+
freshBtns.forEach((b) => b.setAttribute('aria-current', String(b === fb)));
|
|
285
|
+
const sortKey = p.get('sort') ?? 'alpha';
|
|
286
|
+
const sb = sortBtns.find((b) => b.dataset.sort === sortKey) ?? sortBtns[0];
|
|
287
|
+
sort = sb.dataset.sort!;
|
|
288
|
+
sortLabel.textContent = sb.textContent!.replace('✓', '').trim();
|
|
289
|
+
sortBtns.forEach((b) => b.setAttribute('aria-current', String(b === sb)));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function apply(write = true) {
|
|
293
|
+
const q = input.value.trim().toLowerCase();
|
|
294
|
+
const f = {
|
|
295
|
+
pricing: selected('pricing'),
|
|
296
|
+
license: selected('license'),
|
|
297
|
+
language: selected('language'),
|
|
298
|
+
};
|
|
299
|
+
let total = 0;
|
|
300
|
+
for (const section of sections) {
|
|
301
|
+
const grid = section.querySelector<HTMLElement>('[data-grid]')!;
|
|
302
|
+
const cards = Array.from(grid.querySelectorAll<HTMLElement>('[data-tool]'));
|
|
303
|
+
let visible = 0;
|
|
304
|
+
for (const card of cards) {
|
|
305
|
+
const ok =
|
|
306
|
+
(!q || (card.dataset.search ?? '').includes(q)) &&
|
|
307
|
+
(!f.pricing.size ||
|
|
308
|
+
(card.dataset.pricing ?? '').split('|').some((v) => f.pricing.has(v))) &&
|
|
309
|
+
(!f.license.size || f.license.has(card.dataset.license ?? '')) &&
|
|
310
|
+
(!f.language.size ||
|
|
311
|
+
(card.dataset.langs ?? '').split('|').some((v) => f.language.has(v))) &&
|
|
312
|
+
(!fresh ||
|
|
313
|
+
card.dataset.updatemonths === '' ||
|
|
314
|
+
Number(card.dataset.updatemonths) < fresh);
|
|
315
|
+
card.hidden = !ok;
|
|
316
|
+
if (ok) visible++;
|
|
317
|
+
}
|
|
318
|
+
cards
|
|
319
|
+
.filter((c) => !c.hidden)
|
|
320
|
+
.sort(comparator)
|
|
321
|
+
.forEach((c) => grid.appendChild(c));
|
|
322
|
+
section.hidden = visible === 0;
|
|
323
|
+
total += visible;
|
|
324
|
+
}
|
|
325
|
+
noResults.hidden = total !== 0;
|
|
326
|
+
|
|
327
|
+
// Facet count badges + clear-button visibility.
|
|
328
|
+
let anyFilter = q.length > 0 || sort !== 'alpha' || fresh !== 0;
|
|
329
|
+
for (const facet of FACETS) {
|
|
330
|
+
const n = selected(facet).size;
|
|
331
|
+
const badge = root!.querySelector<HTMLElement>(`[data-facet-count="${facet}"]`)!;
|
|
332
|
+
badge.textContent = String(n);
|
|
333
|
+
badge.hidden = n === 0;
|
|
334
|
+
if (n > 0) anyFilter = true;
|
|
335
|
+
}
|
|
336
|
+
clearBtn.hidden = !anyFilter;
|
|
337
|
+
|
|
338
|
+
if (write) writeUrl();
|
|
339
|
+
syncLinks();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
input.addEventListener('input', () => apply());
|
|
343
|
+
boxes.forEach((b) => b.addEventListener('change', () => apply()));
|
|
344
|
+
sortBtns.forEach((btn) =>
|
|
345
|
+
btn.addEventListener('click', () => {
|
|
346
|
+
sort = btn.dataset.sort!;
|
|
347
|
+
sortLabel.textContent = btn.textContent!.replace('✓', '').trim();
|
|
348
|
+
sortBtns.forEach((b) => b.setAttribute('aria-current', String(b === btn)));
|
|
349
|
+
btn.closest('details')?.removeAttribute('open');
|
|
350
|
+
apply();
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
freshBtns.forEach((btn) =>
|
|
354
|
+
btn.addEventListener('click', () => {
|
|
355
|
+
fresh = Number(btn.dataset.fresh);
|
|
356
|
+
freshLabel.textContent = btn.textContent!.replace('✓', '').trim();
|
|
357
|
+
freshBtns.forEach((b) => b.setAttribute('aria-current', String(b === btn)));
|
|
358
|
+
btn.closest('details')?.removeAttribute('open');
|
|
359
|
+
apply();
|
|
360
|
+
}),
|
|
361
|
+
);
|
|
362
|
+
clearBtn.addEventListener('click', () => {
|
|
363
|
+
input.value = '';
|
|
364
|
+
boxes.forEach((b) => (b.checked = false));
|
|
365
|
+
fresh = 0;
|
|
366
|
+
freshBtns.forEach((b) => b.setAttribute('aria-current', String(b === freshBtns[0])));
|
|
367
|
+
freshLabel.textContent = freshBtns[0].textContent!.replace('✓', '').trim();
|
|
368
|
+
sort = 'alpha';
|
|
369
|
+
sortBtns.forEach((b) => b.setAttribute('aria-current', String(b === sortBtns[0])));
|
|
370
|
+
sortLabel.textContent = sortBtns[0].textContent!.replace('✓', '').trim();
|
|
371
|
+
apply();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
try {
|
|
375
|
+
readUrl();
|
|
376
|
+
apply(false); // restore from URL without rewriting it
|
|
377
|
+
} finally {
|
|
378
|
+
// Reveal the now-filtered catalog (always, even if the above throws).
|
|
379
|
+
document.documentElement.removeAttribute('data-prefilter');
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
</script>
|
|
383
|
+
|
|
384
|
+
<script>
|
|
385
|
+
// Listing density toggle. The choice is saved in localStorage and applied as
|
|
386
|
+
// [data-view] on <html> (also set pre-paint in BaseLayout to avoid a flash).
|
|
387
|
+
const group = document.querySelector<HTMLElement>('[data-view-group]');
|
|
388
|
+
if (group) {
|
|
389
|
+
const btns = Array.from(group.querySelectorAll<HTMLButtonElement>('[data-view-btn]'));
|
|
390
|
+
const current = () =>
|
|
391
|
+
document.documentElement.dataset.view === 'gallery' ? 'gallery' : 'grid';
|
|
392
|
+
const sync = () =>
|
|
393
|
+
btns.forEach((b) => b.setAttribute('aria-current', String(b.dataset.viewBtn === current())));
|
|
394
|
+
btns.forEach((b) =>
|
|
395
|
+
b.addEventListener('click', () => {
|
|
396
|
+
const v = b.dataset.viewBtn!;
|
|
397
|
+
if (v === 'gallery') document.documentElement.dataset.view = 'gallery';
|
|
398
|
+
else delete document.documentElement.dataset.view;
|
|
399
|
+
try {
|
|
400
|
+
localStorage.setItem('aas:view', v);
|
|
401
|
+
} catch (e) {}
|
|
402
|
+
sync();
|
|
403
|
+
}),
|
|
404
|
+
);
|
|
405
|
+
sync();
|
|
406
|
+
}
|
|
407
|
+
</script>
|