create-popular-site 0.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 +57 -0
- package/index.mjs +224 -0
- package/package.json +39 -0
- package/templates/aquarium/popular.config.ts +224 -0
- package/templates/aquarium/public/images/hero.png +0 -0
- package/templates/aquarium/public/images/logo.png +0 -0
- package/templates/aquarium/public/images/organizer-1.png +0 -0
- package/templates/aquarium/public/images/organizer-2.png +0 -0
- package/templates/aquarium/public/images/organizer-3.png +0 -0
- package/templates/aquarium/public/images/post-1.png +0 -0
- package/templates/aquarium/public/images/post-2.png +0 -0
- package/templates/aquarium/public/images/post-3.png +0 -0
- package/templates/aquarium/src/content/authors/nadia.md +13 -0
- package/templates/aquarium/src/content/blog/aquarium-field-trip.md +14 -0
- package/templates/aquarium/src/content/blog/fresh-vs-salt-recap.md +23 -0
- package/templates/aquarium/src/content/blog/new-tank-101-recap.md +14 -0
- package/templates/aquarium/src/content/blog/show-and-tell-in-photos.md +21 -0
- package/templates/aquarium/src/content/blog/taken-by-the-sea.md +14 -0
- package/templates/aquarium/src/content/docs/handbook.mdx +127 -0
- package/templates/aquarium/src/content/docs/runbooks.mdx +57 -0
- package/templates/aquarium/src/content/events/aquascaping-night.md +15 -0
- package/templates/aquarium/src/content/events/fresh-vs-salt.md +14 -0
- package/templates/aquarium/src/content/events/new-tank-101.md +14 -0
- package/templates/aquarium/src/content/events/shrimp-social-swap.md +14 -0
- package/templates/aquarium/src/content/events/vancouver-aquarium-trip.md +14 -0
- package/templates/aquarium/src/content/organizers/nadia.md +13 -0
- package/templates/aquarium/src/content/organizers/tom.md +13 -0
- package/templates/aquarium/src/content/organizers/volunteer.md +13 -0
- package/templates/aquarium/src/content/pages/about.mdx +21 -0
- package/templates/aquarium/src/content/pages/code-of-conduct.mdx +38 -0
- package/templates/aquarium/src/content/pages/get-involved.mdx +27 -0
- package/templates/aquarium/src/content/speakers/.gitkeep +0 -0
- package/templates/aquarium/src/content/venues/.gitkeep +0 -0
- package/templates/foodie/popular.config.ts +221 -0
- package/templates/foodie/public/images/hero.png +0 -0
- package/templates/foodie/public/images/logo.png +0 -0
- package/templates/foodie/public/images/organizer-1.png +0 -0
- package/templates/foodie/public/images/organizer-2.png +0 -0
- package/templates/foodie/public/images/organizer-3.png +0 -0
- package/templates/foodie/public/images/post-1.png +0 -0
- package/templates/foodie/public/images/post-2.png +0 -0
- package/templates/foodie/public/images/post-3.png +0 -0
- package/templates/foodie/src/content/authors/sofia.md +7 -0
- package/templates/foodie/src/content/blog/dessert-diary.md +14 -0
- package/templates/foodie/src/content/blog/ice-cream-selfie-flash-mob.md +15 -0
- package/templates/foodie/src/content/blog/pierogi-night-in-photos.md +21 -0
- package/templates/foodie/src/content/blog/poutine-debate.md +12 -0
- package/templates/foodie/src/content/blog/two-breads-and-a-coffee-machine.md +14 -0
- package/templates/foodie/src/content/docs/handbook.mdx +127 -0
- package/templates/foodie/src/content/docs/runbooks.mdx +57 -0
- package/templates/foodie/src/content/events/dessert-crawl.md +14 -0
- package/templates/foodie/src/content/events/pierogi-night.md +16 -0
- package/templates/foodie/src/content/events/poutine-cookoff.md +14 -0
- package/templates/foodie/src/content/events/summer-potluck.md +14 -0
- package/templates/foodie/src/content/organizers/marcus.md +13 -0
- package/templates/foodie/src/content/organizers/sofia.md +13 -0
- package/templates/foodie/src/content/organizers/volunteer.md +13 -0
- package/templates/foodie/src/content/pages/about.mdx +21 -0
- package/templates/foodie/src/content/pages/code-of-conduct.mdx +38 -0
- package/templates/foodie/src/content/pages/get-involved.mdx +27 -0
- package/templates/foodie/src/content/pages/speak.md +23 -0
- package/templates/foodie/src/content/pages/start.md +25 -0
- package/templates/foodie/src/content/speakers/.gitkeep +0 -0
- package/templates/foodie/src/content/venues/.gitkeep +0 -0
- package/templates/kdrama/popular.config.ts +226 -0
- package/templates/kdrama/public/images/hero.png +0 -0
- package/templates/kdrama/public/images/logo.png +0 -0
- package/templates/kdrama/public/images/organizer-1.png +0 -0
- package/templates/kdrama/public/images/organizer-2.png +0 -0
- package/templates/kdrama/public/images/organizer-3.png +0 -0
- package/templates/kdrama/public/images/post-1.png +0 -0
- package/templates/kdrama/public/images/post-2.png +0 -0
- package/templates/kdrama/public/images/post-3.png +0 -0
- package/templates/kdrama/src/content/authors/hana.md +7 -0
- package/templates/kdrama/src/content/blog/attorney-woo-trivia-recap.md +14 -0
- package/templates/kdrama/src/content/blog/castaway-diva-feelings.md +14 -0
- package/templates/kdrama/src/content/blog/crush-1988-me.md +14 -0
- package/templates/kdrama/src/content/blog/karaoke-night-in-photos.mdx +24 -0
- package/templates/kdrama/src/content/blog/vincenzo-recap.md +14 -0
- package/templates/kdrama/src/content/docs/handbook.mdx +127 -0
- package/templates/kdrama/src/content/docs/runbooks.mdx +57 -0
- package/templates/kdrama/src/content/events/attorney-woo-trivia.md +16 -0
- package/templates/kdrama/src/content/events/castaway-diva-finale.md +23 -0
- package/templates/kdrama/src/content/events/ost-karaoke.md +14 -0
- package/templates/kdrama/src/content/events/step-into-the-light.md +14 -0
- package/templates/kdrama/src/content/events/vincenzo-watch-party.md +19 -0
- package/templates/kdrama/src/content/organizers/hana.md +13 -0
- package/templates/kdrama/src/content/organizers/priya.md +13 -0
- package/templates/kdrama/src/content/organizers/volunteer.md +13 -0
- package/templates/kdrama/src/content/pages/about.mdx +21 -0
- package/templates/kdrama/src/content/pages/code-of-conduct.mdx +38 -0
- package/templates/kdrama/src/content/pages/get-involved.mdx +27 -0
- package/templates/kdrama/src/content/pages/speak.md +23 -0
- package/templates/kdrama/src/content/speakers/minji.md +9 -0
- package/templates/kdrama/src/content/venues/half-moon-cafe.md +11 -0
- package/templates/starter/popular.config.ts +228 -0
- package/templates/starter/public/images/hero.png +0 -0
- package/templates/starter/public/images/logo.png +0 -0
- package/templates/starter/public/images/organizer-1.png +0 -0
- package/templates/starter/public/images/post-1.png +0 -0
- package/templates/starter/src/content/authors/sam.md +7 -0
- package/templates/starter/src/content/blog/hello-world.md +12 -0
- package/templates/starter/src/content/docs/handbook.mdx +127 -0
- package/templates/starter/src/content/docs/runbooks.mdx +57 -0
- package/templates/starter/src/content/events/first-meetup.md +16 -0
- package/templates/starter/src/content/events/launch-night.md +20 -0
- package/templates/starter/src/content/organizers/sam.md +7 -0
- package/templates/starter/src/content/pages/about.mdx +22 -0
- package/templates/starter/src/content/pages/code-of-conduct.mdx +38 -0
- package/templates/starter/src/content/pages/get-involved.mdx +27 -0
- package/templates/starter/src/content/pages/speak.md +23 -0
- package/templates/starter/src/content/pages/start.md +25 -0
- package/templates/starter/src/content/speakers/alex.md +7 -0
- package/templates/starter/src/content/venues/community-hall.md +13 -0
- package/templates/superfan/popular.config.ts +235 -0
- package/templates/superfan/public/images/chart.svg +13 -0
- package/templates/superfan/public/images/concert.svg +12 -0
- package/templates/superfan/public/images/hero.svg +38 -0
- package/templates/superfan/public/images/logo.svg +6 -0
- package/templates/superfan/public/images/mic.svg +14 -0
- package/templates/superfan/public/images/news.svg +8 -0
- package/templates/superfan/public/images/riley.svg +9 -0
- package/templates/superfan/public/images/vinyl.svg +12 -0
- package/templates/superfan/src/content/authors/riley.md +7 -0
- package/templates/superfan/src/content/blog/concert-diary-lost-voice.md +14 -0
- package/templates/superfan/src/content/blog/every-era-ranked.md +14 -0
- package/templates/superfan/src/content/blog/new-vinyl-day.md +15 -0
- package/templates/superfan/src/content/blog/welcome-to-the-new-place.md +14 -0
- package/templates/superfan/src/content/docs/concert-prep.mdx +25 -0
- package/templates/superfan/src/content/docs/listening-guide.mdx +39 -0
- package/templates/superfan/src/content/events/fancon-panel.md +17 -0
- package/templates/superfan/src/content/events/podcast-b-sides-show.md +14 -0
- package/templates/superfan/src/content/organizers/.gitkeep +0 -0
- package/templates/superfan/src/content/pages/about.mdx +17 -0
- package/templates/superfan/src/content/pages/say-hi.mdx +23 -0
- package/templates/superfan/src/content/speakers/.gitkeep +0 -0
- package/templates/superfan/src/content/venues/.gitkeep +0 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mariatta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# create-popular-site
|
|
2
|
+
|
|
3
|
+
Start a community site on [Popular](https://popular.mariatta.ca/), a warm,
|
|
4
|
+
community-first Astro theme for meetups, user groups and small events.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm create popular-site@latest
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
or, skipping the questions:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm create popular-site@latest my-community -- --template aquarium --site https://my.community --yes
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Templates
|
|
17
|
+
|
|
18
|
+
| Template | What it is |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `starter` | "Your Community": a neutral skeleton, ready to fill in. The default. |
|
|
21
|
+
| `aquarium` | Rocky Cove Aquarium Club: a fuller meetup example |
|
|
22
|
+
| `foodie` | Lucky Town Foodie Club: a fuller meetup example |
|
|
23
|
+
| `kdrama` | KDrama Fan Club: a fan community with a photo gallery |
|
|
24
|
+
| `superfan` | Truly Madly Riley: a personal site, no organizers |
|
|
25
|
+
|
|
26
|
+
All of them are fictional. The flavored ones are useful when you would rather
|
|
27
|
+
edit something down than fill something in.
|
|
28
|
+
|
|
29
|
+
## What you get
|
|
30
|
+
|
|
31
|
+
A small project that **depends on** the theme rather than containing a copy of
|
|
32
|
+
it:
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
popular.config.ts your settings: name, nav, footer, brand, UI strings
|
|
36
|
+
src/content/ your events, posts, organizers, speakers, venues, docs
|
|
37
|
+
src/content.config.ts one line, adopting the theme's content model
|
|
38
|
+
public/images/ your images
|
|
39
|
+
astro.config.mjs site + base, and the theme integration
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Updating is `npm update astro-theme-popular`. You are not holding a copy of the
|
|
43
|
+
theme, so there is nothing to re-merge, which is what makes a site survive
|
|
44
|
+
being handed to the next organizer.
|
|
45
|
+
|
|
46
|
+
## Options
|
|
47
|
+
|
|
48
|
+
| Flag | Meaning |
|
|
49
|
+
|---|---|
|
|
50
|
+
| `-t`, `--template <name>` | one of the templates above (default `starter`) |
|
|
51
|
+
| `--site <url>` | the URL the site will be served from. A project-page URL like `https://you.github.io/my-community/` is split into Astro's `site` and `base` for you. |
|
|
52
|
+
| `-y`, `--yes` | accept defaults, ask nothing (also the behavior when stdin is not a TTY) |
|
|
53
|
+
|
|
54
|
+
## Licence
|
|
55
|
+
|
|
56
|
+
MIT. The theme, its demos and this scaffolder all ship from
|
|
57
|
+
[Mariatta/astro-theme-popular](https://github.com/Mariatta/astro-theme-popular).
|
package/index.mjs
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* create-popular-site: start a community site on the Popular Astro theme.
|
|
4
|
+
*
|
|
5
|
+
* npm create popular-site@latest
|
|
6
|
+
* npm create popular-site@latest my-community -- --template aquarium
|
|
7
|
+
* npm create popular-site@latest my-community -- --template starter --site https://my.community --yes
|
|
8
|
+
*
|
|
9
|
+
* Writes a small project that *depends on* astro-theme-popular rather than
|
|
10
|
+
* vendoring it, so the site updates with `npm update` instead of by diffing
|
|
11
|
+
* tags and re-copying files. That is the whole reason the theme was packaged
|
|
12
|
+
* (PACKAGING.md, "Why").
|
|
13
|
+
*
|
|
14
|
+
* Templates ship inside this package, collected from the repo's demo
|
|
15
|
+
* workspaces at pack time (scripts/collect-templates.mjs). `starter` is the
|
|
16
|
+
* neutral "Your Community" skeleton; the rest are the fictional demos, useful
|
|
17
|
+
* as a fuller example to edit down.
|
|
18
|
+
*
|
|
19
|
+
* Dependency-free on purpose: `npm create` runs this before the user has
|
|
20
|
+
* agreed to install anything, and the theme's other generator
|
|
21
|
+
* (scripts/setup.py) is stdlib-only for the same reason.
|
|
22
|
+
*/
|
|
23
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { dirname, join, resolve } from 'node:path';
|
|
25
|
+
import { createInterface } from 'node:readline/promises';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { stdin, stdout } from 'node:process';
|
|
28
|
+
|
|
29
|
+
const HERE = resolve(dirname(fileURLToPath(import.meta.url)));
|
|
30
|
+
const TEMPLATE_DIR = join(HERE, 'templates');
|
|
31
|
+
const { version: VERSION } = JSON.parse(readFileSync(join(HERE, 'package.json'), 'utf8'));
|
|
32
|
+
|
|
33
|
+
const TEMPLATES = {
|
|
34
|
+
starter: 'Your Community: a neutral skeleton, ready to fill in',
|
|
35
|
+
aquarium: 'Rocky Cove Aquarium Club: a fuller meetup example',
|
|
36
|
+
foodie: 'Lucky Town Foodie Club: a fuller meetup example',
|
|
37
|
+
kdrama: 'KDrama Fan Club: a fan community with a photo gallery',
|
|
38
|
+
superfan: 'Truly Madly Riley: a personal site, no organizers',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const die = (msg) => {
|
|
42
|
+
console.error(`create-popular-site: ${msg}`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function parseArgs(argv) {
|
|
47
|
+
const out = { dir: undefined, template: undefined, site: undefined, yes: false };
|
|
48
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
49
|
+
const arg = argv[i];
|
|
50
|
+
if (arg === '--yes' || arg === '-y') out.yes = true;
|
|
51
|
+
else if (arg === '--template' || arg === '-t') out.template = argv[++i];
|
|
52
|
+
else if (arg === '--site') out.site = argv[++i];
|
|
53
|
+
else if (arg === '--help' || arg === '-h') out.help = true;
|
|
54
|
+
else if (arg.startsWith('-')) die(`unknown option ${arg}`);
|
|
55
|
+
else if (out.dir === undefined) out.dir = arg;
|
|
56
|
+
else die(`unexpected argument ${arg}`);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const HELP = `create-popular-site ${VERSION}
|
|
62
|
+
|
|
63
|
+
npm create popular-site@latest [directory] -- [options]
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
-t, --template <name> ${Object.keys(TEMPLATES).join(', ')} (default: starter)
|
|
67
|
+
--site <url> the URL the site will be served from
|
|
68
|
+
-y, --yes accept defaults, ask nothing
|
|
69
|
+
-h, --help show this
|
|
70
|
+
|
|
71
|
+
Docs: https://popular.mariatta.ca/docs/quick-start/?fw=astro
|
|
72
|
+
`;
|
|
73
|
+
|
|
74
|
+
/* A project name npm will accept: lowercase, no spaces, no leading dot. */
|
|
75
|
+
const packageName = (dir) =>
|
|
76
|
+
dir.split('/').filter(Boolean).pop()
|
|
77
|
+
.toLowerCase()
|
|
78
|
+
.replace(/[^a-z0-9-~][^a-z0-9-._~]*/g, '-')
|
|
79
|
+
.replace(/^[-._]+|[-._]+$/g, '') || 'my-community';
|
|
80
|
+
|
|
81
|
+
/* Astro wants the origin and the subpath separately, and a GitHub project page
|
|
82
|
+
needs both. Splitting one answer keeps the question count down; the same
|
|
83
|
+
split lives in scripts/setup.py (split_site_base). */
|
|
84
|
+
function splitSiteBase(url) {
|
|
85
|
+
if (!url) return { site: 'https://example.com', base: '/' };
|
|
86
|
+
let parsed;
|
|
87
|
+
try {
|
|
88
|
+
parsed = new URL(url);
|
|
89
|
+
} catch {
|
|
90
|
+
die(`--site must be a full URL (got '${url}')`);
|
|
91
|
+
}
|
|
92
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
93
|
+
return { site: parsed.origin, base: path === '' ? '/' : path };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const astroConfig = ({ site, base }) => `import { defineConfig } from 'astro/config';
|
|
97
|
+
import mdx from '@astrojs/mdx';
|
|
98
|
+
import popular from 'astro-theme-popular';
|
|
99
|
+
|
|
100
|
+
/* \`site\` is your origin and \`base\` is the path your site is served from.
|
|
101
|
+
A GitHub project page lives at you.github.io/my-community/, so its base is
|
|
102
|
+
'/my-community'; leave it '/' for a site at a domain root. Every link, image
|
|
103
|
+
and feed URL the theme writes follows both, markdown bodies included. */
|
|
104
|
+
export default defineConfig({
|
|
105
|
+
site: '${site}',
|
|
106
|
+
base: '${base}',
|
|
107
|
+
integrations: [mdx(), popular()],
|
|
108
|
+
});
|
|
109
|
+
`;
|
|
110
|
+
|
|
111
|
+
const packageJson = (name) => `${JSON.stringify({
|
|
112
|
+
name,
|
|
113
|
+
private: true,
|
|
114
|
+
type: 'module',
|
|
115
|
+
scripts: { dev: 'astro dev', build: 'astro build', preview: 'astro preview' },
|
|
116
|
+
dependencies: {
|
|
117
|
+
'@astrojs/mdx': '^7.0.2',
|
|
118
|
+
astro: '^7.0.6',
|
|
119
|
+
'astro-theme-popular': `^${VERSION}`,
|
|
120
|
+
},
|
|
121
|
+
}, null, 2)}\n`;
|
|
122
|
+
|
|
123
|
+
const CONTENT_CONFIG = `// The theme package owns the content model; this file just adopts it.
|
|
124
|
+
// Add your own collections here if you need them.
|
|
125
|
+
export { collections } from 'astro-theme-popular/schemas';
|
|
126
|
+
`;
|
|
127
|
+
|
|
128
|
+
const GITIGNORE = `dist/
|
|
129
|
+
node_modules/
|
|
130
|
+
.astro/
|
|
131
|
+
.DS_Store
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
const readme = (name, template) => `# ${name}
|
|
135
|
+
|
|
136
|
+
A community site built with [Popular](https://popular.mariatta.ca/), started
|
|
137
|
+
from the \`${template}\` template.
|
|
138
|
+
|
|
139
|
+
\`\`\`bash
|
|
140
|
+
npm install
|
|
141
|
+
npm run dev
|
|
142
|
+
\`\`\`
|
|
143
|
+
|
|
144
|
+
- **Your settings** live in \`popular.config.ts\`: name, navigation, footer,
|
|
145
|
+
brand colours, and the UI strings.
|
|
146
|
+
- **Your content** lives in \`src/content/\`: events, blog posts, organizers,
|
|
147
|
+
speakers, venues, and the handbook.
|
|
148
|
+
- **Your images** live in \`public/images/\`.
|
|
149
|
+
- **Updating the theme** is \`npm update astro-theme-popular\`. You are not
|
|
150
|
+
holding a copy of it, so there is nothing to re-merge.
|
|
151
|
+
|
|
152
|
+
Docs: https://popular.mariatta.ca/docs/?fw=astro
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
async function main() {
|
|
156
|
+
const args = parseArgs(process.argv.slice(2));
|
|
157
|
+
if (args.help) {
|
|
158
|
+
stdout.write(HELP);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const interactive = !args.yes && stdin.isTTY;
|
|
163
|
+
const rl = interactive ? createInterface({ input: stdin, output: stdout }) : null;
|
|
164
|
+
const ask = async (question, fallback) => {
|
|
165
|
+
if (!rl) return fallback;
|
|
166
|
+
const answer = (await rl.question(question)).trim();
|
|
167
|
+
return answer === '' ? fallback : answer;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
if (interactive) stdout.write(`\nPopular ${VERSION}: a community-first Astro theme.\n\n`);
|
|
172
|
+
|
|
173
|
+
const dir = args.dir ?? await ask('Directory [my-community]: ', 'my-community');
|
|
174
|
+
|
|
175
|
+
let template = args.template;
|
|
176
|
+
if (!template && interactive) {
|
|
177
|
+
stdout.write('\nTemplates:\n');
|
|
178
|
+
for (const [slug, blurb] of Object.entries(TEMPLATES)) {
|
|
179
|
+
stdout.write(` ${slug.padEnd(9)} ${blurb}\n`);
|
|
180
|
+
}
|
|
181
|
+
template = await ask('\nTemplate [starter]: ', 'starter');
|
|
182
|
+
}
|
|
183
|
+
template ??= 'starter';
|
|
184
|
+
if (!Object.hasOwn(TEMPLATES, template)) {
|
|
185
|
+
die(`unknown template '${template}'. Choose one of: ${Object.keys(TEMPLATES).join(', ')}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const siteUrl = args.site ?? await ask(
|
|
189
|
+
'\nSite URL, if you know it [https://example.com]: ', 'https://example.com');
|
|
190
|
+
|
|
191
|
+
const target = resolve(process.cwd(), dir);
|
|
192
|
+
if (existsSync(target) && readdirSync(target).length > 0) {
|
|
193
|
+
die(`${dir} already exists and is not empty`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const from = join(TEMPLATE_DIR, template);
|
|
197
|
+
if (!existsSync(from)) {
|
|
198
|
+
die(`this build of create-popular-site has no '${template}' template ` +
|
|
199
|
+
`(templates/ is populated at pack time; see scripts/collect-templates.mjs)`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const name = packageName(dir);
|
|
203
|
+
mkdirSync(target, { recursive: true });
|
|
204
|
+
cpSync(from, target, { recursive: true });
|
|
205
|
+
mkdirSync(join(target, 'src'), { recursive: true });
|
|
206
|
+
writeFileSync(join(target, 'src', 'content.config.ts'), CONTENT_CONFIG);
|
|
207
|
+
writeFileSync(join(target, 'astro.config.mjs'), astroConfig(splitSiteBase(siteUrl)));
|
|
208
|
+
writeFileSync(join(target, 'package.json'), packageJson(name));
|
|
209
|
+
writeFileSync(join(target, '.gitignore'), GITIGNORE);
|
|
210
|
+
writeFileSync(join(target, 'README.md'), readme(name, template));
|
|
211
|
+
|
|
212
|
+
stdout.write(`\nCreated ${name} in ${dir} from the ${template} template.\n\n`);
|
|
213
|
+
stdout.write('Next:\n');
|
|
214
|
+
stdout.write(` cd ${dir}\n`);
|
|
215
|
+
stdout.write(' npm install\n');
|
|
216
|
+
stdout.write(' npm run dev\n\n');
|
|
217
|
+
stdout.write('Then edit popular.config.ts to make it yours.\n');
|
|
218
|
+
stdout.write('Docs: https://popular.mariatta.ca/docs/quick-start/?fw=astro\n\n');
|
|
219
|
+
} finally {
|
|
220
|
+
rl?.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
await main();
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "create-popular-site",
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Start a community site on the Popular Astro theme: npm create popular-site@latest",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Mariatta",
|
|
8
|
+
"homepage": "https://popular.mariatta.ca/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Mariatta/astro-theme-popular.git",
|
|
12
|
+
"directory": "create-popular-site"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"astro",
|
|
16
|
+
"astro-theme",
|
|
17
|
+
"create",
|
|
18
|
+
"community",
|
|
19
|
+
"meetup"
|
|
20
|
+
],
|
|
21
|
+
"bin": {
|
|
22
|
+
"create-popular-site": "index.mjs"
|
|
23
|
+
},
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"prepack": "node scripts/collect-templates.mjs"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"index.mjs",
|
|
32
|
+
"templates",
|
|
33
|
+
"README.md",
|
|
34
|
+
"LICENSE"
|
|
35
|
+
],
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/** Popular: theme configuration (Rocky Cove Aquarium Club demo). See README for all options. */
|
|
2
|
+
|
|
3
|
+
export const SITE = {
|
|
4
|
+
title: "Rocky Cove Aquarium Club",
|
|
5
|
+
tagline: "A fictional aquarium club, demo content for the Popular theme",
|
|
6
|
+
description: "A friendly club for freshwater and saltwater keepers in Rocky Cove. (Rocky Cove is a made-up place, this is example content demonstrating the Popular theme.)",
|
|
7
|
+
brandName: "Rocky Cove",
|
|
8
|
+
brandSub: "Aquarium Club",
|
|
9
|
+
logo: '/images/logo.png',
|
|
10
|
+
favicon: '/images/logo.png',
|
|
11
|
+
ogImage: '/images/hero.png',
|
|
12
|
+
locale: 'en-US',
|
|
13
|
+
landAcknowledgement: "Add your community's land acknowledgement or welcome statement here, this demo keeps it as a placeholder.",
|
|
14
|
+
notice: {
|
|
15
|
+
text: 'Winter break: regular meetups resume in March. **Tanks stay wet**, [pop into the chat](https://example.com/chat) any time.',
|
|
16
|
+
url: 'https://example.com/news',
|
|
17
|
+
},
|
|
18
|
+
community: { chat: { url: 'https://example.com/discord', label: 'Join our Discord' } },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/* UI strings. Translate your site by editing these values; keys map 1:1 to
|
|
22
|
+
the Hugo theme's i18n/en.toml (see PARITY.md). */
|
|
23
|
+
export const STRINGS: Record<string, string> = {
|
|
24
|
+
skipToContent: 'Skip to content',
|
|
25
|
+
primaryNav: 'Primary',
|
|
26
|
+
toggleMenu: 'Toggle menu',
|
|
27
|
+
nextMeetup: 'Next meetup',
|
|
28
|
+
venueTba: 'Venue to be announced',
|
|
29
|
+
eventCancelled: 'Cancelled',
|
|
30
|
+
rsvp: 'RSVP',
|
|
31
|
+
rsvpExternal: 'RSVP on Luma',
|
|
32
|
+
allEvents: 'All events',
|
|
33
|
+
allSpeakers: 'All speakers',
|
|
34
|
+
allVenues: 'All venues',
|
|
35
|
+
allOrganizers: 'All organizers',
|
|
36
|
+
comingUp: 'Coming up',
|
|
37
|
+
upcomingEvents: 'Upcoming events',
|
|
38
|
+
upcomingLead: 'Save your seat early, space is limited and our rooms fill up.',
|
|
39
|
+
noUpcomingEvents: 'Nothing on the calendar right now, follow us to hear when the next one lands.',
|
|
40
|
+
lookingBack: 'Looking back',
|
|
41
|
+
pastEvents: 'Past events',
|
|
42
|
+
venueWanted: 'Venue wanted',
|
|
43
|
+
confirmed: 'Confirmed',
|
|
44
|
+
fromTheBlog: 'From the blog',
|
|
45
|
+
latestFromCommunity: 'Latest from our community',
|
|
46
|
+
readTheBlog: 'Read the blog',
|
|
47
|
+
allPosts: 'All posts',
|
|
48
|
+
filterLabel: 'Filter:',
|
|
49
|
+
filterAll: 'All',
|
|
50
|
+
subscribeRss: 'Subscribe via RSS',
|
|
51
|
+
subscribeCalendar: 'Subscribe to calendar',
|
|
52
|
+
watchRecording: 'Watch',
|
|
53
|
+
viewSlides: 'Slides',
|
|
54
|
+
hasRecording: 'Recording available',
|
|
55
|
+
talks: 'Talks',
|
|
56
|
+
talksLead: 'Every talk from our past events, with recordings and slides where we have them.',
|
|
57
|
+
learnMore: 'Learn more',
|
|
58
|
+
joinChat: 'Join the chat',
|
|
59
|
+
speakerInviteTitle: 'Your name here?',
|
|
60
|
+
speakerInviteBody: 'We are always looking for new voices. Lightning talks welcome, and we will help you prepare.',
|
|
61
|
+
subscribeRssTitle: 'Follow new posts in any feed reader',
|
|
62
|
+
noPosts: 'No posts yet, check back soon.',
|
|
63
|
+
ourSpeaker: 'Our speaker',
|
|
64
|
+
minRead: '{n} min read',
|
|
65
|
+
authorsBy: 'By',
|
|
66
|
+
listSeparator: ', ',
|
|
67
|
+
listSeparatorFinal: ' & ',
|
|
68
|
+
posts: 'Posts',
|
|
69
|
+
guest: 'Guest',
|
|
70
|
+
website: 'Website',
|
|
71
|
+
previewLabel: 'Preview',
|
|
72
|
+
previewBanner: 'This is a preview. Nothing here is published yet.',
|
|
73
|
+
checklist: 'Checklist',
|
|
74
|
+
copyCode: 'Copy code',
|
|
75
|
+
checklistDone: 'done',
|
|
76
|
+
writtenBy: 'Written by {name}',
|
|
77
|
+
eyebrowBlog: 'Blog',
|
|
78
|
+
eyebrowEvents: 'Events',
|
|
79
|
+
eyebrowAuthor: 'Author',
|
|
80
|
+
eyebrowDocs: 'Docs',
|
|
81
|
+
eyebrowTag: 'Tag',
|
|
82
|
+
taggedCount: '{n} post(s) tagged {tag}',
|
|
83
|
+
speakers: 'Speakers',
|
|
84
|
+
eyebrowSpeaker: 'Speaker',
|
|
85
|
+
eyebrowOrganizer: 'Organizer',
|
|
86
|
+
sessionsBy: 'Sessions by {name}',
|
|
87
|
+
eyebrowVenue: 'Venue',
|
|
88
|
+
gettingThere: 'Getting there & access',
|
|
89
|
+
wheelchairAccessible: 'Wheelchair accessible',
|
|
90
|
+
transit: 'Transit',
|
|
91
|
+
parking: 'Parking',
|
|
92
|
+
venueEvents: 'Events held here',
|
|
93
|
+
beforeYouArrive: 'Before you arrive',
|
|
94
|
+
postNavigation: 'More posts',
|
|
95
|
+
newerPost: 'Newer post',
|
|
96
|
+
olderPost: 'Older post',
|
|
97
|
+
paginationLabel: 'Pagination',
|
|
98
|
+
paginationPrev: 'Previous',
|
|
99
|
+
paginationNext: 'Next',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/* List pagination size. Mirrors Hugo's [pagination] pagerSize (see PARITY.md). */
|
|
103
|
+
export const PAGINATION = { pageSize: 3 };
|
|
104
|
+
|
|
105
|
+
/* Renameable content sections: point post-author bylines or the homepage
|
|
106
|
+
team grid at a differently-named collection (must exist in your
|
|
107
|
+
content.config.ts). Defaults are the theme's own section names.
|
|
108
|
+
Hugo parity: [params.sections] (see PARITY.md). */
|
|
109
|
+
export const SECTIONS_MAP = {
|
|
110
|
+
authors: 'authors',
|
|
111
|
+
team: 'organizers',
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const BRAND: Record<string, string> = {
|
|
115
|
+
primary: "#0f766e",
|
|
116
|
+
primaryHover: "#0d5f59",
|
|
117
|
+
primaryActive: "#0b4f4a",
|
|
118
|
+
secondary: "#0b4f4a",
|
|
119
|
+
accent: "#b45309",
|
|
120
|
+
ink: '#142a36',
|
|
121
|
+
surfaceWash: "#d7ece9",
|
|
122
|
+
surfaceWashSoft: "#ecf6f4",
|
|
123
|
+
surfaceInk: '#142a36',
|
|
124
|
+
fontSans: 'Inter, system-ui, sans-serif',
|
|
125
|
+
fontDisplay: 'Quantico, Inter, sans-serif',
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const NAV: {
|
|
129
|
+
label: string;
|
|
130
|
+
href: string;
|
|
131
|
+
children?: { label: string; href: string }[];
|
|
132
|
+
}[] = [
|
|
133
|
+
{
|
|
134
|
+
label: 'About',
|
|
135
|
+
href: '/about/',
|
|
136
|
+
children: [
|
|
137
|
+
{ label: 'About us', href: '/about/' },
|
|
138
|
+
{ label: 'Organizers', href: '/organizers/' },
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
{ label: 'Blog', href: '/blog/' },
|
|
142
|
+
{ label: 'Events', href: '/events/' },
|
|
143
|
+
{ label: 'Handbook', href: '/handbook/' },
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
export const CTA = { label: 'RSVP', url: 'https://example.com/rsvp', icon: 'fa-solid fa-calendar' };
|
|
147
|
+
|
|
148
|
+
export const SOCIAL = [
|
|
149
|
+
{ label: 'Instagram', icon: 'fa-brands fa-instagram', url: 'https://example.com' },
|
|
150
|
+
{ label: 'Mastodon', icon: 'fa-brands fa-mastodon', url: 'https://example.com' },
|
|
151
|
+
{ label: 'RSS', icon: 'fa-solid fa-rss', url: '/rss.xml' },
|
|
152
|
+
{ label: 'Email', icon: 'fa-solid fa-envelope', url: 'mailto:hello@example.com' },
|
|
153
|
+
];
|
|
154
|
+
|
|
155
|
+
export const FOOTER = {
|
|
156
|
+
tagline: "Fish · Plants · Community, an Astro demo site for the Popular theme",
|
|
157
|
+
credit: { label: 'Popular. An Astro theme by Mariatta.', url: 'https://popular.mariatta.ca/' },
|
|
158
|
+
columns: [
|
|
159
|
+
{ title: 'Community', links: [
|
|
160
|
+
{ label: 'About us', href: '/about/' },
|
|
161
|
+
{ label: 'Blog', href: '/blog/' },
|
|
162
|
+
{ label: 'Code of Conduct', href: '/code-of-conduct/' },
|
|
163
|
+
]},
|
|
164
|
+
{ title: 'Get involved', links: [
|
|
165
|
+
{ label: 'Host a session', href: '/get-involved/' },
|
|
166
|
+
{ label: 'Offer a venue', href: '/get-involved/' },
|
|
167
|
+
{ label: 'Organizers', href: '/organizers/' },
|
|
168
|
+
]},
|
|
169
|
+
{ title: 'Organizers', links: [
|
|
170
|
+
{ label: 'Organizer Handbook', href: '/handbook/' },
|
|
171
|
+
{ label: 'Meetup Runbooks', href: '/runbooks/' },
|
|
172
|
+
]},
|
|
173
|
+
],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** Support box at the bottom of blog posts, null hides it. */
|
|
177
|
+
export const SUPPORT = null;
|
|
178
|
+
|
|
179
|
+
/** Section list-page headers. */
|
|
180
|
+
export const SECTIONS = {
|
|
181
|
+
blog: { eyebrow: 'Blog', title: 'From our community', lead: "Recaps and spotlights from Rocky Cove Aquarium Club, example posts showing the theme's blog." },
|
|
182
|
+
events: { eyebrow: 'Events', title: 'Upcoming gatherings & announcements', lead: 'All events are free, and newcomers are always welcome.' },
|
|
183
|
+
organizers: { eyebrow: 'The team', title: 'Organizers', lead: "Rocky Cove Aquarium Club is run entirely by volunteers. Want to help? We'd love to have you." },
|
|
184
|
+
speakers: { eyebrow: 'Speakers', title: 'Speakers', lead: 'Everyone who has taken the mic at one of our gatherings.' },
|
|
185
|
+
venues: { eyebrow: 'Venues', title: 'Venues', lead: 'The rooms that host us.' },
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
export const HOME = {
|
|
189
|
+
hero: {
|
|
190
|
+
eyebrow: "Fish · Plants · Community",
|
|
191
|
+
title: "For everyone who's ever lost an hour watching a tank",
|
|
192
|
+
lead: "Rocky Cove Aquarium Club is a friendly group of freshwater and saltwater keepers. Whether you run a nano shrimp bowl or a full reef system, or you're still dreaming about your first tank, you're welcome here.",
|
|
193
|
+
image: '/images/hero.png',
|
|
194
|
+
ctas: [
|
|
195
|
+
{ label: 'RSVP', url: 'https://example.com/rsvp', icon: 'fa-solid fa-calendar', variant: 'primary' },
|
|
196
|
+
{ label: 'About us', url: '/about/', variant: 'outline' },
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
stats: [{ value: "2019", label: "club founded" }, { value: "All", label: "tanks welcome" }, { value: "Free", label: "to attend" }, { value: "Monthly", label: "meets in Rocky Cove" }],
|
|
200
|
+
featuresHead: { eyebrow: 'What we do', title: "What Rocky Cove Aquarium Club is all about", lead: 'No experience necessary, come as you are.' },
|
|
201
|
+
features: [
|
|
202
|
+
{ icon: "fa-solid fa-fish", title: "Show & tell nights", body: "Bring photos (or a jar of shrimp) and share what's growing, spawning or misbehaving in your tank." },
|
|
203
|
+
{ icon: "fa-solid fa-seedling", title: "Hands-on workshops", body: "Aquascaping, planted tanks and CO₂, water chemistry, learn by doing, with helpers at every table." },
|
|
204
|
+
{ icon: "fa-solid fa-arrows-rotate", title: "Swap meets", body: "Trade plants, shrimp and frags with other keepers. One keeper's trimmings are another's new scape." },
|
|
205
|
+
],
|
|
206
|
+
testimonialsHead: { eyebrow: 'Kind words', title: 'What members say' },
|
|
207
|
+
testimonials: [
|
|
208
|
+
{ quote: "I joined with one betta. I now run three tanks and have zero regrets.", name: "Priya", role: "Freshwater keeper" },
|
|
209
|
+
{ quote: "The only place where 'my tank crashed' gets you sympathy and a rescue plan in the same breath.", name: "Marcus", role: "Reef keeper" },
|
|
210
|
+
{ quote: "The swap meet furnished my entire aquascape. My wallet thanks this club.", name: "Elle", role: "Plant person" },
|
|
211
|
+
],
|
|
212
|
+
getInvolved: {
|
|
213
|
+
eyebrow: 'Community',
|
|
214
|
+
title: "There's a seat (and a tank) for everyone",
|
|
215
|
+
lead: "Host a session, offer a venue, or just come watch the fish with us.",
|
|
216
|
+
ctas: [
|
|
217
|
+
{ label: 'Get involved', url: '/get-involved/', variant: 'primary' },
|
|
218
|
+
{ label: 'Read the handbook', url: '/handbook/', variant: 'outline' },
|
|
219
|
+
],
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
/** Demo-only switcher bar. Adopters: set to null (or delete the demo bar usage). */
|
|
224
|
+
export const DEMO_BAR = null;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Nadia Reyes"
|
|
3
|
+
role: "Lead organizer"
|
|
4
|
+
photo: "/images/organizer-1.png"
|
|
5
|
+
bio: "Keeps the club running, welcomes newcomers, and can identify most plecos on sight."
|
|
6
|
+
website: "https://example.com"
|
|
7
|
+
social:
|
|
8
|
+
- label: "Mastodon"
|
|
9
|
+
icon: "fa-brands fa-mastodon"
|
|
10
|
+
url: "https://example.com"
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
Nadia founded Rocky Cove Aquarium Club after her third "one last tank." She writes most of our recaps.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Field trip diary: a day at the Vancouver Aquarium"
|
|
3
|
+
date: 2026-02-25
|
|
4
|
+
author: "Nadia"
|
|
5
|
+
description: "Photos and highlights from our winter outing."
|
|
6
|
+
image: "/images/post-3.png"
|
|
7
|
+
tags: ["field-trip", "community"]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Fifteen of us spent a rainy Saturday exactly where we wanted to be: in front of very large tanks.
|
|
11
|
+
|
|
12
|
+
Highlights included the kelp forest exhibit, a spirited discussion about sump design, and one member taking ninety-two photos of the same anemone.
|
|
13
|
+
|
|
14
|
+
We left inspired, humbled, and with a shared list of wildly unrealistic dream-tank plans.
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Freshwater vs saltwater: what our panel actually agreed on"
|
|
3
|
+
date: 2026-04-20
|
|
4
|
+
guestAuthors:
|
|
5
|
+
- name: "Wren Alvarez"
|
|
6
|
+
title: "Guest writer · reef keeper"
|
|
7
|
+
photo: "/images/organizer-2.png"
|
|
8
|
+
bio: "Wren keeps a 90-gallon reef and moderates a saltwater forum. This is a one-off guest recap, no author profile page needed."
|
|
9
|
+
website: "https://example.com"
|
|
10
|
+
social:
|
|
11
|
+
- label: "Instagram"
|
|
12
|
+
icon: "fa-brands fa-instagram"
|
|
13
|
+
url: "https://example.com"
|
|
14
|
+
description: "Two camps, one hobby, the friendly debate, recapped."
|
|
15
|
+
image: "/images/post-2.png"
|
|
16
|
+
tags: ["panel", "community"]
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
We expected a rivalry; we got a love-in. Both panels agreed on more than they disagreed: stability beats gadgets, water changes beat additives, and the best tank is the one you'll actually maintain.
|
|
20
|
+
|
|
21
|
+
The saltwater camp made their case with a rock-solid frag display; the freshwater camp countered with a carpet of Monte Carlo that drew audible gasps.
|
|
22
|
+
|
|
23
|
+
We'll call it a draw, until the rematch.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "New tank 101 recap: five tanks, zero panic"
|
|
3
|
+
date: 2026-06-24
|
|
4
|
+
authors: ["nadia"]
|
|
5
|
+
description: "What twenty beginners learned setting up their first tanks together."
|
|
6
|
+
image: "/images/post-1.png"
|
|
7
|
+
tags: ["workshop", "beginners"]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
Our biggest beginner night yet. Twenty new keepers, five demo tanks, and one very patient discussion about the nitrogen cycle.
|
|
11
|
+
|
|
12
|
+
The highlight was watching first-timers realize that a beautiful tank is mostly patience: cycle first, stock slowly, and let the plants do the work.
|
|
13
|
+
|
|
14
|
+
Thank you to everyone who brought spare equipment for the giveaway table, three members went home with their first complete setup.
|