simple-site-generator 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +165 -2
- package/dist/commands/add-page.js +50 -0
- package/dist/commands/build.js +20 -0
- package/dist/commands/check.js +53 -0
- package/dist/commands/init.js +229 -0
- package/dist/commands/verify.js +150 -0
- package/dist/pages/page-schema.js +161 -0
- package/dist/pages/render-page.js +46 -0
- package/dist/paths.js +27 -0
- package/dist/types/cli.d.ts +1 -0
- package/dist/types/commands/add-page.d.ts +26 -0
- package/dist/types/commands/build.d.ts +18 -0
- package/dist/types/commands/check.d.ts +20 -0
- package/dist/types/commands/init.d.ts +40 -0
- package/dist/types/commands/verify.d.ts +47 -0
- package/dist/types/pages/page-schema.d.ts +81 -0
- package/dist/types/pages/render-page.d.ts +26 -0
- package/dist/types/paths.d.ts +16 -0
- package/dist/types/verify/budgets.d.ts +114 -0
- package/dist/types/verify/class-coverage.d.ts +65 -0
- package/dist/types/verify/ignore.d.ts +8 -0
- package/dist/types/verify/lighthouse.d.ts +27 -0
- package/dist/types/verify/seo-surface.d.ts +23 -0
- package/dist/types/verify/serve.d.ts +6 -0
- package/dist/verify/budgets.js +180 -0
- package/dist/verify/class-coverage.js +114 -0
- package/dist/verify/ignore.js +11 -0
- package/dist/verify/lighthouse.js +98 -0
- package/dist/verify/seo-surface.js +97 -0
- package/dist/verify/serve.js +123 -0
- package/package.json +14 -8
- package/templates/app/.github/workflows/deploy-rsync.yaml.hbs +58 -0
- package/templates/app/.oxfmtrc.json +32 -0
- package/templates/app/.oxlintrc.json +10 -0
- package/templates/app/.vscode/extensions.json +3 -0
- package/templates/app/build/Caddyfile.hbs +24 -0
- package/templates/app/build/Caddyfile.preview +34 -0
- package/templates/app/index.html +35 -0
- package/templates/app/logo.svg +143 -0
- package/templates/app/pnpm-workspace.yaml +10 -0
- package/templates/app/public/favicon-128.png +0 -0
- package/templates/app/public/favicon-48.png +0 -0
- package/templates/app/public/favicon-96.png +0 -0
- package/templates/app/public/favicon.ico +0 -0
- package/templates/app/public/images/landing/landing.png +0 -0
- package/templates/app/public/robots.txt +5 -0
- package/templates/app/src/App.vue +22 -0
- package/templates/app/src/pages/Contact.md +25 -0
- package/templates/app/src/pages/Error404Page.vue +31 -0
- package/templates/app/src/pages/GettingStarted/GettingStarted.md +52 -0
- package/templates/app/src/pages/Index.md.hbs +46 -0
- package/templates/app/src/router/index.ts +21 -0
- package/templates/app/src/router/routes.ts +218 -0
- package/templates/app/src/setup.ts +8 -0
- package/templates/app/src/shims-virtual.d.ts +67 -0
- package/templates/app/src/shims-vue.d.ts +6 -0
- package/templates/app/vitrify.config.ts.hbs +249 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: gettingstarted
|
|
3
|
+
title: Getting started
|
|
4
|
+
description: Scaffold a project, add pages and verify the build before you deploy.
|
|
5
|
+
label: Getting started
|
|
6
|
+
icon:
|
|
7
|
+
name: i-mdi-rocket-launch
|
|
8
|
+
tags:
|
|
9
|
+
- menu
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
## Scaffold a project
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm dlx simple-site-generator init my-site --template landing
|
|
16
|
+
cd my-site
|
|
17
|
+
pnpm install
|
|
18
|
+
pnpm dev
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Replace `landing` with `docs` or `business` to start from a different template.
|
|
22
|
+
|
|
23
|
+
## Add a page
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
ssg add-page services --title "Services" --tags menu
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This writes `src/pages/services.md` with frontmatter that already satisfies the
|
|
30
|
+
page contract. Add `--page Landing` to render it with the conversion layout.
|
|
31
|
+
|
|
32
|
+
## Check, build, verify
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
ssg check # validate every page's frontmatter
|
|
36
|
+
ssg build # prerender the site into dist/static
|
|
37
|
+
ssg verify # SEO surface, byte budgets and Lighthouse
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`ssg verify` fails the run on a broken SEO surface (a missing description, two
|
|
41
|
+
titles, unparseable JSON-LD) or on an exceeded byte budget, and warns when
|
|
42
|
+
Lighthouse performance is below target.
|
|
43
|
+
|
|
44
|
+
## Deploy
|
|
45
|
+
|
|
46
|
+
The build output is `dist/static` — plain files. Copy them to any static host.
|
|
47
|
+
The included GitHub workflow uses `rsync` to push to your own server; define
|
|
48
|
+
`DEPLOY_HOST`, `DEPLOY_USER` and `DEPLOY_KEY` as repository secrets.
|
|
49
|
+
|
|
50
|
+
> **Note**
|
|
51
|
+
> Change the `template` value in `vitrify.config.ts` to switch between `docs`,
|
|
52
|
+
> `landing` and `business`.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
---
|
|
2
|
+
id: home
|
|
3
|
+
title: Home
|
|
4
|
+
description: A fast, static website built with Quasar and Vitrify — edit the
|
|
5
|
+
content as Markdown and deploy the generated files anywhere.
|
|
6
|
+
label: Home
|
|
7
|
+
{{#if landingPage}}
|
|
8
|
+
page: Landing
|
|
9
|
+
{{/if}}
|
|
10
|
+
tags:
|
|
11
|
+
- menu
|
|
12
|
+
icon:
|
|
13
|
+
name: i-mdi-home
|
|
14
|
+
cta:
|
|
15
|
+
message: Get started
|
|
16
|
+
route: /gettingstarted
|
|
17
|
+
cards:
|
|
18
|
+
- header: Content in Markdown
|
|
19
|
+
content: Write pages as Markdown files with frontmatter; the templates turn
|
|
20
|
+
them into a real site.
|
|
21
|
+
- header: Static and fast
|
|
22
|
+
content: Every page is prerendered to HTML, so it loads without waiting on
|
|
23
|
+
JavaScript.
|
|
24
|
+
- header: One command to verify
|
|
25
|
+
content: ssg verify checks the SEO surface, the byte budgets and Lighthouse
|
|
26
|
+
before you deploy.
|
|
27
|
+
images:
|
|
28
|
+
- name: Landing
|
|
29
|
+
src: images/landing/landing.png
|
|
30
|
+
caption: A simple site generator project
|
|
31
|
+
# The hero's animated process graphic — a route with named stops, drawn from
|
|
32
|
+
# this list. Replace the stops with your own process; two to five read best.
|
|
33
|
+
# `landing` shows it only when a page declares no hero image, and `business`
|
|
34
|
+
# shows it in the page's title band instead of a background illustration.
|
|
35
|
+
demo:
|
|
36
|
+
label: From Markdown to a deployed site
|
|
37
|
+
steps:
|
|
38
|
+
- Write Markdown
|
|
39
|
+
- Build
|
|
40
|
+
- Deploy
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Why use this
|
|
44
|
+
|
|
45
|
+
Write Markdown, get a fast static site. The pages below are the starting point —
|
|
46
|
+
replace them with your own content.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/// <reference types="vitrify/client" />
|
|
2
|
+
import {
|
|
3
|
+
createMemoryHistory,
|
|
4
|
+
createRouter as _createRouter,
|
|
5
|
+
createWebHistory
|
|
6
|
+
} from 'vue-router'
|
|
7
|
+
import routes from './routes'
|
|
8
|
+
|
|
9
|
+
export default function createRouter() {
|
|
10
|
+
return _createRouter({
|
|
11
|
+
// use appropriate history implementation for server/client
|
|
12
|
+
// import.meta.env.SSR is injected by Vite.
|
|
13
|
+
history: import.meta.env.SSR
|
|
14
|
+
? createMemoryHistory(__BASE_URL__)
|
|
15
|
+
: createWebHistory(__BASE_URL__),
|
|
16
|
+
scrollBehavior(_to, _from, _savedPosition) {
|
|
17
|
+
return { top: 0 }
|
|
18
|
+
},
|
|
19
|
+
routes
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { RouteRecordRaw } from 'vue-router'
|
|
2
|
+
// The slim virtual module carries ONLY the site identity the client needs.
|
|
3
|
+
// Importing `virtual:vitrify-config` instead inlined the entire build config
|
|
4
|
+
// (including the whole UnoCSS/Quasar option tree) into the browser bundle —
|
|
5
|
+
// 205 KB raw / 109 KB minified on the previous version of this file.
|
|
6
|
+
import { site } from 'virtual:ssg:site'
|
|
7
|
+
import { QNoSsr } from 'quasar'
|
|
8
|
+
import { defineAsyncComponent, h } from 'vue'
|
|
9
|
+
import { Layout } from 'virtual:ssg:template'
|
|
10
|
+
|
|
11
|
+
const { tags, extra } = site
|
|
12
|
+
|
|
13
|
+
/** Frontmatter a page file may declare (see CONTENT-BLUEPRINT.md). */
|
|
14
|
+
interface PageFrontmatter {
|
|
15
|
+
id?: string
|
|
16
|
+
title?: string
|
|
17
|
+
label?: string
|
|
18
|
+
description?: string
|
|
19
|
+
/** Sort weight; the home page is always first. */
|
|
20
|
+
order?: number
|
|
21
|
+
/** Name of the template component to render, e.g. `Landing`. */
|
|
22
|
+
page?: string
|
|
23
|
+
/** Render this page client-side only. */
|
|
24
|
+
noSsr?: boolean
|
|
25
|
+
/** Keep the page out of the index and the sitemap. */
|
|
26
|
+
noindex?: boolean
|
|
27
|
+
image?: string
|
|
28
|
+
/**
|
|
29
|
+
* BCP-47 tag when this page is not in the site's default language, e.g. `nl`.
|
|
30
|
+
* Drives `<html lang>`, `og:locale` and the JSON-LD `inLanguage`.
|
|
31
|
+
*/
|
|
32
|
+
lang?: string
|
|
33
|
+
/**
|
|
34
|
+
* Other-language versions of this page: `{ lang, path, label? }`.
|
|
35
|
+
*
|
|
36
|
+
* Emitted as `rel="alternate"` hreflang links, and rendered as the language
|
|
37
|
+
* switch in the header (which hides the entry pointing at the current page,
|
|
38
|
+
* so a single-language site shows no switch at all).
|
|
39
|
+
*
|
|
40
|
+
* The pairing is app-specific — declare it per page, or derive it here as
|
|
41
|
+
* `simsustech/website` does for its `nl-`-prefixed pages.
|
|
42
|
+
*/
|
|
43
|
+
alternates?: { lang: string; path: string; label?: string }[]
|
|
44
|
+
breadcrumbs?: string[]
|
|
45
|
+
tags?: string[]
|
|
46
|
+
[key: string]: unknown
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface PageModule {
|
|
50
|
+
attributes?: PageFrontmatter
|
|
51
|
+
html?: string
|
|
52
|
+
default?: unknown
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Markdown and Vue pages, eagerly imported so the route table is complete at
|
|
57
|
+
* module-evaluation time (prerendering needs every route to exist up front).
|
|
58
|
+
*
|
|
59
|
+
* The pattern MUST be relative. The previous version was a bare `src/pages` path,
|
|
60
|
+
* which Vite 8 rejects outright — "Invalid glob … It must start with '/' or
|
|
61
|
+
* './'" — so the glob resolved to nothing and every page silently disappeared
|
|
62
|
+
* from the build. The extglob alternation that also changed is NOT the cause: the
|
|
63
|
+
* extglob and brace forms match identically once the leading './' is present, and
|
|
64
|
+
* `test/page-glob.test.ts` pins that.
|
|
65
|
+
*
|
|
66
|
+
* Note for editors: never write a glob containing a `*` immediately followed by a
|
|
67
|
+
* `/` inside a block comment — the `*` + `/` pair closes the comment early and
|
|
68
|
+
* the rest of the comment is parsed as code.
|
|
69
|
+
*/
|
|
70
|
+
const pagesImport = import.meta.glob(
|
|
71
|
+
['../pages/**/*.{md,vue}', '!**/Error404Page.vue'],
|
|
72
|
+
{ eager: true }
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
const noSsrComponent = {
|
|
76
|
+
name: 'NoSSRWrapper',
|
|
77
|
+
render(this: { component: unknown; page: unknown }) {
|
|
78
|
+
const component = defineAsyncComponent(this.component as never)
|
|
79
|
+
return h(QNoSsr, null, {
|
|
80
|
+
default: () => h(component, this.page as Record<string, unknown>)
|
|
81
|
+
})
|
|
82
|
+
},
|
|
83
|
+
props: ['page', 'component']
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function attributesOf(page: unknown): PageFrontmatter {
|
|
87
|
+
return (page as PageModule | undefined)?.attributes ?? {}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Home first, then pages with an explicit `order`, then the rest alphabetically.
|
|
92
|
+
*/
|
|
93
|
+
const orderedPages = [
|
|
94
|
+
...Object.entries(pagesImport)
|
|
95
|
+
.filter(([, page]) => {
|
|
96
|
+
const { id, order = 0 } = attributesOf(page)
|
|
97
|
+
return order || id === 'home'
|
|
98
|
+
})
|
|
99
|
+
.sort(([, pageA], [, pageB]) => {
|
|
100
|
+
const { id: idA, order: orderA = 0 } = attributesOf(pageA)
|
|
101
|
+
const { id: idB, order: orderB = 0 } = attributesOf(pageB)
|
|
102
|
+
|
|
103
|
+
if (idA === 'home') return -1
|
|
104
|
+
if (idB === 'home') return 1
|
|
105
|
+
|
|
106
|
+
return orderA - orderB
|
|
107
|
+
}),
|
|
108
|
+
...Object.entries(pagesImport).filter(([, page]) => {
|
|
109
|
+
const { id, order = 0 } = attributesOf(page)
|
|
110
|
+
return !order && id && id !== 'home'
|
|
111
|
+
})
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
const pageList = orderedPages.map(([, page]) => attributesOf(page))
|
|
115
|
+
|
|
116
|
+
const routes: RouteRecordRaw[] = [
|
|
117
|
+
{
|
|
118
|
+
path: '/',
|
|
119
|
+
component: Layout,
|
|
120
|
+
props: {
|
|
121
|
+
...site,
|
|
122
|
+
pages: pageList,
|
|
123
|
+
tags,
|
|
124
|
+
extra
|
|
125
|
+
},
|
|
126
|
+
children: [
|
|
127
|
+
...orderedPages.map(([, page]) => {
|
|
128
|
+
const { id = '', page: componentName = '', noSsr } = attributesOf(page)
|
|
129
|
+
const { attributes = {}, html = '' } = (page ?? {}) as PageModule
|
|
130
|
+
|
|
131
|
+
const vuePage = async () => {
|
|
132
|
+
if ((page as PageModule).default) {
|
|
133
|
+
return (page as PageModule).default
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return import('virtual:ssg:template').then((module) => {
|
|
137
|
+
const templates = module.default
|
|
138
|
+
|
|
139
|
+
// No component named: the template's `Page` is the default, which is
|
|
140
|
+
// what most pages want.
|
|
141
|
+
if (!componentName) return module.Page
|
|
142
|
+
|
|
143
|
+
const component = templates[componentName]
|
|
144
|
+
|
|
145
|
+
// A named component that does not exist used to fall back to
|
|
146
|
+
// `module.Page` silently. That is the worst possible outcome for a
|
|
147
|
+
// field that decides a page's entire layout: the page renders, looks
|
|
148
|
+
// plausible, and is the wrong template.
|
|
149
|
+
//
|
|
150
|
+
// Thrown during prerender, so it fails the build rather than
|
|
151
|
+
// shipping.
|
|
152
|
+
if (!component) {
|
|
153
|
+
const available = Object.keys(templates)
|
|
154
|
+
.sort((a, b) => a.localeCompare(b))
|
|
155
|
+
.join(', ')
|
|
156
|
+
|
|
157
|
+
throw new Error(
|
|
158
|
+
`Unknown page template component "${componentName}" on page "${id}".\n` +
|
|
159
|
+
` \`page:\` names a component from the configured template module,\n` +
|
|
160
|
+
` which provides: ${available}. Most pages should omit \`page:\`\n` +
|
|
161
|
+
` entirely and get the template's default \`Page\`.`
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return component
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const props = {
|
|
170
|
+
...attributes,
|
|
171
|
+
content: html,
|
|
172
|
+
pages: pageList,
|
|
173
|
+
// The business details (phone, reviews, service area) live on the site
|
|
174
|
+
// config, not in each page's frontmatter, but the landing page needs them
|
|
175
|
+
// for its proof/CTA sections — so they are passed down here rather than
|
|
176
|
+
// duplicated into every page.
|
|
177
|
+
extra
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
// The home page lives at the parent path, so its child path is empty.
|
|
182
|
+
path: id === 'home' ? '' : id,
|
|
183
|
+
name: id,
|
|
184
|
+
component: noSsr ? noSsrComponent : vuePage,
|
|
185
|
+
props: noSsr ? { page: props, component: vuePage } : props,
|
|
186
|
+
alias: id === 'home' ? ['home'] : [],
|
|
187
|
+
/**
|
|
188
|
+
* The single source of truth about a page, read by both the shell (for
|
|
189
|
+
* `<head>` tags) and the build (for the sitemap).
|
|
190
|
+
*/
|
|
191
|
+
meta: {
|
|
192
|
+
id,
|
|
193
|
+
title: attributes.title,
|
|
194
|
+
description: attributes.description,
|
|
195
|
+
noindex: attributes.noindex ?? false,
|
|
196
|
+
image: attributes.image,
|
|
197
|
+
lang: attributes.lang,
|
|
198
|
+
alternates: attributes.alternates,
|
|
199
|
+
breadcrumbs: attributes.breadcrumbs
|
|
200
|
+
}
|
|
201
|
+
} as RouteRecordRaw
|
|
202
|
+
}),
|
|
203
|
+
|
|
204
|
+
// The catch-all is a CHILD of the layout on purpose. As a sibling it
|
|
205
|
+
// rendered outside the shell, so it had no header, no footer and — because
|
|
206
|
+
// `useSeo` lives in the layout — no `<head>` tags at all. Vue Router ranks
|
|
207
|
+
// a catch-all last, so it cannot shadow a real page.
|
|
208
|
+
{
|
|
209
|
+
path: ':catchAll(.*)*',
|
|
210
|
+
name: 'not-found',
|
|
211
|
+
component: () => import('../pages/Error404Page.vue'),
|
|
212
|
+
meta: { id: '404', title: 'Page not found', noindex: true }
|
|
213
|
+
}
|
|
214
|
+
]
|
|
215
|
+
}
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
export default routes
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Types for the virtual modules the build injects. Without these, every import
|
|
2
|
+
// from `virtual:ssg:*` is an unresolved module in the editor and in any
|
|
3
|
+
// type-check of this app.
|
|
4
|
+
|
|
5
|
+
declare module 'virtual:ssg:site' {
|
|
6
|
+
interface SsgTag {
|
|
7
|
+
label: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface SsgExtra {
|
|
11
|
+
phone?: string
|
|
12
|
+
email?: string
|
|
13
|
+
address?: string
|
|
14
|
+
instagramUrl?: string
|
|
15
|
+
facebookUrl?: string
|
|
16
|
+
mapUrl?: string
|
|
17
|
+
rating?: number
|
|
18
|
+
reviewCount?: number
|
|
19
|
+
reviewSource?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SsgBusiness {
|
|
23
|
+
type?: string
|
|
24
|
+
name?: string
|
|
25
|
+
description?: string
|
|
26
|
+
telephone?: string
|
|
27
|
+
email?: string
|
|
28
|
+
address?: {
|
|
29
|
+
streetAddress?: string
|
|
30
|
+
postalCode?: string
|
|
31
|
+
addressLocality?: string
|
|
32
|
+
addressCountry?: string
|
|
33
|
+
}
|
|
34
|
+
areaServed?: string[]
|
|
35
|
+
openingHours?: { days: string[]; opens: string; closes: string }[]
|
|
36
|
+
priceRange?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Site identity only. Deliberately NOT the whole build config: this module is
|
|
41
|
+
* evaluated in the browser, so anything added here ships to every visitor.
|
|
42
|
+
*/
|
|
43
|
+
export const site: {
|
|
44
|
+
title: string
|
|
45
|
+
url: string
|
|
46
|
+
favicon?: string
|
|
47
|
+
description?: string
|
|
48
|
+
lang?: string
|
|
49
|
+
image?: string
|
|
50
|
+
twitter?: string
|
|
51
|
+
template: string
|
|
52
|
+
templatePackage: string
|
|
53
|
+
tags: Record<string, SsgTag>
|
|
54
|
+
extra?: SsgExtra
|
|
55
|
+
jsonLd?: { business?: SsgBusiness }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
declare module 'virtual:ssg:template' {
|
|
60
|
+
import type { Component } from 'vue'
|
|
61
|
+
|
|
62
|
+
export const Layout: Component
|
|
63
|
+
export const Page: Component
|
|
64
|
+
/** Optional page components a template may expose, e.g. `Landing`. */
|
|
65
|
+
const template: Record<string, Component>
|
|
66
|
+
export default template
|
|
67
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import type { VitrifyConfig, VitrifyConfigAsync } from 'vitrify'
|
|
2
|
+
import { QuasarPlugin, type QuasarPluginOptions } from 'vitrify/plugins'
|
|
3
|
+
import type { QuasarIconSet } from 'quasar'
|
|
4
|
+
// `QuasarPreset` is exported by the preset, NOT by vitrify. Importing it from
|
|
5
|
+
// 'vitrify' fails at load time ("does not provide an export named
|
|
6
|
+
// 'QuasarPreset'"), and since 0.7 the preset also refuses to run without a
|
|
7
|
+
// style, so both the import and `style` below are required.
|
|
8
|
+
import { QuasarPreset, quasarIconSet } from 'unocss-preset-quasar'
|
|
9
|
+
import { MaterialDesign3 } from 'unocss-preset-quasar/styles'
|
|
10
|
+
import { certificateFor } from 'devcert'
|
|
11
|
+
import { plugin as mdPlugin } from 'vite-plugin-markdown'
|
|
12
|
+
import { Mode } from 'vite-plugin-markdown'
|
|
13
|
+
import { copyFile } from 'fs/promises'
|
|
14
|
+
import { ssgSitemap } from '@simple-site-generator/templates/seo'
|
|
15
|
+
import { ssgUnoConfig } from '@simple-site-generator/templates/design'
|
|
16
|
+
|
|
17
|
+
interface SimpleSiteGenerator {
|
|
18
|
+
ssg: {
|
|
19
|
+
title: string
|
|
20
|
+
url: string
|
|
21
|
+
/** Site description: the sitemap's, and the fallback meta description. */
|
|
22
|
+
description: string
|
|
23
|
+
template: string
|
|
24
|
+
templatePackage: string
|
|
25
|
+
favicon: string
|
|
26
|
+
lang: string
|
|
27
|
+
sourceColor: string
|
|
28
|
+
tags: Record<string, { label: string }>
|
|
29
|
+
extra?: Record<string, unknown>
|
|
30
|
+
jsonLd?: { business?: Record<string, unknown> }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Annotated with the interface rather than left to inference: the three reads
|
|
35
|
+
// of `ssg.description`, `ssg.extra` and `ssg.jsonLd` below happen on values a
|
|
36
|
+
// bare object literal does not have, so an unannotated literal makes every
|
|
37
|
+
// generated project start with five `Property … does not exist` errors.
|
|
38
|
+
const ssg: SimpleSiteGenerator['ssg'] = {
|
|
39
|
+
title: '{{ productName }}',
|
|
40
|
+
url: '{{ url }}',
|
|
41
|
+
description: '{{ description }}',
|
|
42
|
+
favicon: '/favicon-96.png',
|
|
43
|
+
// Rendered from the `--template` value passed to `ssg init`. It selects which
|
|
44
|
+
// template module the app imports, so it must be templated, not hardcoded.
|
|
45
|
+
template: '{{ template }}',
|
|
46
|
+
templatePackage: '@simple-site-generator/templates',
|
|
47
|
+
// BCP-47 tag for og:locale, JSON-LD `inLanguage`, and — through
|
|
48
|
+
// `vitrify.lang` below — the `<html lang>` that Quasar writes. It must name
|
|
49
|
+
// one of Quasar's language packs (node_modules/quasar/lang), e.g. `en-US`,
|
|
50
|
+
// `en-GB`, `nl`; a bare `en` has no pack and Quasar falls back to `en-US`.
|
|
51
|
+
lang: 'en-US',
|
|
52
|
+
// The only colour input. MD3 derives its whole tonal palette — every role
|
|
53
|
+
// token the design system reads, in light and dark mode — from this one value.
|
|
54
|
+
// A `colors` map beside it looks load-bearing and is not: it produced utility
|
|
55
|
+
// classes that drifted away from the templates' own tokens, so there is no
|
|
56
|
+
// second place to set a colour.
|
|
57
|
+
sourceColor: '#22c92a',
|
|
58
|
+
tags: {
|
|
59
|
+
menu: {
|
|
60
|
+
label: 'Menu'
|
|
61
|
+
},
|
|
62
|
+
footer: {
|
|
63
|
+
label: 'Footer'
|
|
64
|
+
},
|
|
65
|
+
contact: {
|
|
66
|
+
label: 'Contact'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const { template, templatePackage, title } = ssg
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Quasar's own icons, resolved to iconify classes.
|
|
75
|
+
*
|
|
76
|
+
* Handing Quasar the library *name* lets it pick its own font classes
|
|
77
|
+
* (`mdi mdi-menu-down`), and this app loads no icon font — so every icon the
|
|
78
|
+
* framework draws itself (the dropdown arrow, a select's chevron, a table's
|
|
79
|
+
* expander) renders as nothing. The rewritten set fixes that: each value is a
|
|
80
|
+
* class the preset emits CSS for.
|
|
81
|
+
*
|
|
82
|
+
* The resolution's `iconMapFn` is deliberately not passed — Vitrify serialises
|
|
83
|
+
* the Quasar config into the client bundle and a function does not survive, so
|
|
84
|
+
* passing it makes the arrow throw instead of render. Bare icon names need that
|
|
85
|
+
* mapper; write `i-mdi-*` names instead.
|
|
86
|
+
*/
|
|
87
|
+
const icons = quasarIconSet('mdi-v7')
|
|
88
|
+
|
|
89
|
+
const quasarConf: QuasarPluginOptions = {
|
|
90
|
+
framework: {
|
|
91
|
+
components: [],
|
|
92
|
+
// 'Meta' is what makes Quasar emit the per-page <title>, description,
|
|
93
|
+
// canonical, OpenGraph and JSON-LD tags. Without it `ssrContext._meta.headTags`
|
|
94
|
+
// stays empty and a prerendered page ships no metadata at all.
|
|
95
|
+
plugins: ['Dialog', 'Notify', 'Loading', 'Meta'],
|
|
96
|
+
// A Quasar icon-library name. The preset derives the nested icon set from
|
|
97
|
+
// `quasar/dist/icon-set`, rewrites each value to its iconify class and
|
|
98
|
+
// safelists them — so the ~200-line hand-written icon map is not needed.
|
|
99
|
+
// The rewritten set is what Quasar renders from, above; the preset types it
|
|
100
|
+
// as `unknown`, so the assertion to Quasar's own type belongs here.
|
|
101
|
+
iconSet: icons.iconSet as QuasarIconSet
|
|
102
|
+
},
|
|
103
|
+
/*
|
|
104
|
+
* Disable SASS if you use UnoCSS
|
|
105
|
+
*/
|
|
106
|
+
disableSass: true
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export default async function ({
|
|
110
|
+
mode
|
|
111
|
+
}: Parameters<VitrifyConfigAsync>[0]): Promise<VitrifyConfig> {
|
|
112
|
+
const config: VitrifyConfig & SimpleSiteGenerator = {
|
|
113
|
+
optimizeDeps: {
|
|
114
|
+
exclude: ['@simple-site-generator/templates']
|
|
115
|
+
},
|
|
116
|
+
plugins: [
|
|
117
|
+
mdPlugin({
|
|
118
|
+
mode: [Mode.HTML]
|
|
119
|
+
}),
|
|
120
|
+
// Emits sitemap.xml and robots.txt into dist/static, generated from the
|
|
121
|
+
// same page frontmatter the router reads.
|
|
122
|
+
ssgSitemap({
|
|
123
|
+
site: {
|
|
124
|
+
title: ssg.title,
|
|
125
|
+
url: ssg.url,
|
|
126
|
+
description: ssg.description,
|
|
127
|
+
favicon: ssg.favicon,
|
|
128
|
+
lang: ssg.lang,
|
|
129
|
+
extra: ssg.extra,
|
|
130
|
+
jsonLd: ssg.jsonLd
|
|
131
|
+
},
|
|
132
|
+
pagesDir: new URL('./src/pages', import.meta.url).pathname,
|
|
133
|
+
distDir: new URL('./dist', import.meta.url).pathname
|
|
134
|
+
}),
|
|
135
|
+
{
|
|
136
|
+
name: 'simple-site-generator-plugin',
|
|
137
|
+
resolveId(id: string) {
|
|
138
|
+
if (id === 'virtual:ssg:template' || id === 'virtual:ssg:site') {
|
|
139
|
+
return `\0${id}`
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
load(id: string) {
|
|
143
|
+
if (id === '\0virtual:ssg:template') {
|
|
144
|
+
return `import * as template from '${templatePackage}/${template}';
|
|
145
|
+
export * from '${templatePackage}/${template}';
|
|
146
|
+
export default template;`
|
|
147
|
+
}
|
|
148
|
+
if (id === '\0virtual:ssg:site') {
|
|
149
|
+
// ONLY the identity the browser needs. The previous version imported
|
|
150
|
+
// `virtual:vitrify-config`, which inlined the whole build config
|
|
151
|
+
// (UnoCSS presets, Quasar options, icon map) into the client bundle.
|
|
152
|
+
// The client reads exactly these fields, so anything else added here
|
|
153
|
+
// would ship to every visitor.
|
|
154
|
+
return `export const site = ${JSON.stringify({
|
|
155
|
+
title: ssg.title,
|
|
156
|
+
url: ssg.url,
|
|
157
|
+
favicon: ssg.favicon,
|
|
158
|
+
lang: ssg.lang,
|
|
159
|
+
template: ssg.template,
|
|
160
|
+
templatePackage: ssg.templatePackage,
|
|
161
|
+
tags: ssg.tags,
|
|
162
|
+
extra: ssg.extra,
|
|
163
|
+
jsonLd: ssg.jsonLd
|
|
164
|
+
})};`
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
closeBundle: () => {
|
|
168
|
+
copyFile(
|
|
169
|
+
new URL('./build/Caddyfile', import.meta.url).pathname,
|
|
170
|
+
new URL(`./dist/static/Caddyfile`, import.meta.url).pathname
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
],
|
|
175
|
+
ssg,
|
|
176
|
+
vitrify: {
|
|
177
|
+
// Registering the Quasar plugin is what makes `useQuasar()` and every
|
|
178
|
+
// `q-*` component work during SSR. Without it a prerendered page throws
|
|
179
|
+
// "Cannot read properties of undefined (reading 'screen')" and ships an
|
|
180
|
+
// empty <div id="app">.
|
|
181
|
+
plugins: [
|
|
182
|
+
{
|
|
183
|
+
plugin: QuasarPlugin,
|
|
184
|
+
options: quasarConf
|
|
185
|
+
}
|
|
186
|
+
],
|
|
187
|
+
productName: title,
|
|
188
|
+
// Pass the BCP-47 tag to Quasar as well: Quasar writes `<html lang>`
|
|
189
|
+
// from its selected language pack, and vitrify forwards this value to it.
|
|
190
|
+
lang: ssg.lang,
|
|
191
|
+
hooks: {
|
|
192
|
+
onSetup: [new URL('src/setup.ts', import.meta.url)]
|
|
193
|
+
},
|
|
194
|
+
unocss: {
|
|
195
|
+
presets: [
|
|
196
|
+
QuasarPreset({
|
|
197
|
+
style: MaterialDesign3,
|
|
198
|
+
sourceColor: ssg.sourceColor,
|
|
199
|
+
plugins: quasarConf.framework.plugins,
|
|
200
|
+
iconSet: 'mdi-v7',
|
|
201
|
+
// Only the third-party Quasar UI libraries whose styles this app
|
|
202
|
+
// needs contribute CSS; declaring them is what ports their
|
|
203
|
+
// selectors. The prose styles are not among them: the design system
|
|
204
|
+
// styles the markdown output itself (`ssg-prose`), so no Markdown
|
|
205
|
+
// component or stylesheet is needed on either side.
|
|
206
|
+
appExtensions: ['qmediaplayer']
|
|
207
|
+
})
|
|
208
|
+
],
|
|
209
|
+
// The templates' design system: theme scale, shortcuts, preflights and
|
|
210
|
+
// the safelist of its class names.
|
|
211
|
+
...ssgUnoConfig,
|
|
212
|
+
theme: ssgUnoConfig.theme,
|
|
213
|
+
content: {
|
|
214
|
+
pipeline: {
|
|
215
|
+
include: [
|
|
216
|
+
/\.(vue|svelte|[jt]sx|mdx?|astro|elm|php|phtml|html)($|\?)/,
|
|
217
|
+
// The templates package IS scanned, and has to be: a template
|
|
218
|
+
// component renders `q-separator` and `i-mdi-phone` as readily as
|
|
219
|
+
// it renders an `ssg-*` class, and `unocss-preset-quasar` is what
|
|
220
|
+
// turns both into CSS. Measuring this include against the site's
|
|
221
|
+
// own sources showed 137 selectors that exist *only* because of
|
|
222
|
+
// it — `q-separator`, `q-card__actions--*`, `q-btn-group*` and the
|
|
223
|
+
// icons among them. Dropping it looks like a free 17 KB and is
|
|
224
|
+
// actually an unstyled drawer with missing icons.
|
|
225
|
+
//
|
|
226
|
+
// The shortcut names are safelisted separately (`ssgSafelist`), so
|
|
227
|
+
// it is not the shortcut CSS this supplies — it is the Quasar and
|
|
228
|
+
// icon classes. The noise that rides along with it is bounded by
|
|
229
|
+
// the design system's own `blocklist`.
|
|
230
|
+
new RegExp(`${ssg.templatePackage}.*\\.js`)
|
|
231
|
+
]
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
// No `sass.global` block: the media player's styles come from the preset's
|
|
236
|
+
// `appExtensions`, and the old `QMarkdown.sass` path does not exist (the
|
|
237
|
+
// file is `.scss`), so it only produced a resolution error.
|
|
238
|
+
ssr: {
|
|
239
|
+
serverModules: ['vite-plugin-markdown']
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (mode === 'development') {
|
|
244
|
+
config.server = {
|
|
245
|
+
https: await certificateFor('vitrify.test')
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return config
|
|
249
|
+
}
|