@myelophone/nuxt 0.19.1 → 0.20.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/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## 📦 Release 0.20.0 (2026-09-04)
2
+ ### ✨ Features
3
+ - add ssr stream
4
+
5
+ ### 📚 Documentation
6
+ - update readme
7
+
1
8
  ## 📦 Release 0.19.1 (2026-08-30)
2
9
  ### 🐛 Bug Fixes
3
10
  - update github actions
package/README.md CHANGED
@@ -100,17 +100,17 @@ export default defineNuxtConfig({
100
100
 
101
101
  ### Extend it as a Nuxt layer
102
102
 
103
- The layer can be installed directly from GitHub without registry configuration, access tokens, or GitHub Packages authentication. Add the dependency to the consuming application's `package.json`:
103
+ The layer is published on [npm](https://www.npmjs.com/package/@myelophone/nuxt). Add it to the consuming application's `package.json`:
104
104
 
105
105
  ```json
106
106
  {
107
107
  "devDependencies": {
108
- "@myelophone/nuxt": "git+https://github.com/myelophone/nuxt.git"
108
+ "@myelophone/nuxt": "^0.19.1"
109
109
  }
110
110
  }
111
111
  ```
112
112
 
113
- Install dependencies and prepare Nuxt types:
113
+ Install dependencies from npm and prepare Nuxt types:
114
114
 
115
115
  ```bash
116
116
  yarn install
@@ -542,7 +542,7 @@ const props = withDefaults(
542
542
 
543
543
  const english: ProductPageContent = {
544
544
  seo: {
545
- title: "Acme — a clearer way to work",
545
+ title: "Acme - a clearer way to work",
546
546
  description: "A short description of the complete product page.",
547
547
  },
548
548
  brand: "Acme",
@@ -558,7 +558,7 @@ const defaultContentByLocale: Record<string, ProductPageContent> = {
558
558
  en: english,
559
559
  pl: mergeReadyContent(english, {
560
560
  seo: {
561
- title: "Acme — prostszy sposób pracy",
561
+ title: "Acme - prostszy sposób pracy",
562
562
  description: "Krótki opis kompletnej strony produktu.",
563
563
  },
564
564
  hero: {
@@ -995,7 +995,7 @@ Use `SeoProfileInfo` only when the page primarily describes one person or organi
995
995
  <SeoProfileInfo
996
996
  id="https://example.com/team/ada#profile-page"
997
997
  url="https://example.com/team/ada"
998
- name="Ada Lovelace — profile"
998
+ name="Ada Lovelace - profile"
999
999
  date-created="2024-01-10T09:00:00Z"
1000
1000
  date-modified="2026-08-16T12:00:00Z"
1001
1001
  entity-id="https://example.com/team/ada#person"
@@ -2977,7 +2977,7 @@ Commits follow Conventional Commits and are checked with Commitlint. The `main`
2977
2977
  - `BREAKING CHANGE:` → major release;
2978
2978
  - documentation, tests, refactors, build, CI, style, and chores do not release by default.
2979
2979
 
2980
- The release workflow updates `CHANGELOG.md` and `package.json`, publishes to GitHub Packages, creates a GitHub release, and commits release metadata back to the repository.
2980
+ The release workflow updates `CHANGELOG.md` and `package.json`, publishes to [npm](https://www.npmjs.com/package/@myelophone/nuxt) with provenance, creates a GitHub release, and commits release metadata back to the repository.
2981
2981
 
2982
2982
  ### Compatibility checklist
2983
2983
 
package/app/app.vue CHANGED
@@ -354,7 +354,7 @@ useCommand({
354
354
  <NuxtLayout>
355
355
  <NuxtErrorBoundary>
356
356
  <main id="main" tabindex="-1">
357
- <NuxtPage :keepalive="{ max: 10 }" />
357
+ <NuxtPage />
358
358
  </main>
359
359
  <template #error="{ error, clearError }">
360
360
  <SeoNoIndex :status-code="error?.statusCode" />
@@ -59,8 +59,8 @@ onUnmounted(deactivateNotFound);
59
59
  const { t } = useMultiLang(["interface"]);
60
60
 
61
61
  useAppSeo({
62
- title: () => `404 — ${t("interface.pageNotFound")}`,
63
- description: () => `404 — ${t("interface.pageNotFoundDescription")}`,
62
+ title: () => `404 - ${t("interface.pageNotFound")}`,
63
+ description: () => `404 - ${t("interface.pageNotFoundDescription")}`,
64
64
  noIndex: true,
65
65
  });
66
66
  </script>
@@ -174,6 +174,14 @@ export function useMultiLang(namespaces: string | string[]) {
174
174
  { watch: [activeLang] },
175
175
  );
176
176
 
177
+ if (import.meta.client) {
178
+ onMounted(() => {
179
+ void Promise.all(
180
+ nsArray.map((ns) => loadNamespace(activeLang.value, ns)),
181
+ );
182
+ });
183
+ }
184
+
177
185
  const t = (
178
186
  key: string,
179
187
  arg1?: InterpolationParams | number,
@@ -21,7 +21,9 @@ export default defineNuxtRouteMiddleware(async (to) => {
21
21
  if (!post) {
22
22
  const event = useRequestEvent();
23
23
 
24
- if (event) {
24
+ const isIslandRequest = Boolean(useNuxtApp().ssrContext?.islandContext);
25
+
26
+ if (event && !isIslandRequest) {
25
27
  setResponseStatus(event, 404, 'Not Found');
26
28
  }
27
29
  }
@@ -20,7 +20,7 @@ function processPages(pages: NuxtPage[], blogEnabled: boolean) {
20
20
  for (let i = pages.length - 1; i >= 0; i--) {
21
21
  const page = pages[i]!;
22
22
 
23
- if (page.file?.endsWith('/post/[...slug].vue')) {
23
+ if (page.file?.endsWith('/post/[...slug].server.vue')) {
24
24
  if (!blogEnabled) {
25
25
  pages.splice(i, 1);
26
26
  continue;
@@ -33,6 +33,52 @@ const enableDefaultRouteStreaming = (routeRules: Record<string, any>) => {
33
33
  };
34
34
  };
35
35
 
36
+ const disablePostRouteStreaming = (
37
+ routeRules: Record<string, any>,
38
+ locales: string[],
39
+ ) => {
40
+ const postRoutes = new Set([
41
+ '/post/**',
42
+ ...locales.map((locale) => `/${locale}/post/**`),
43
+ ]);
44
+
45
+ for (const route of postRoutes) {
46
+ routeRules[route] = {
47
+ ...(routeRules[route] && typeof routeRules[route] === 'object'
48
+ ? routeRules[route]
49
+ : {}),
50
+ streaming: false,
51
+ };
52
+ }
53
+ };
54
+
55
+ const addSWRPayloadRoutes = (routeRules: Record<string, any>) => {
56
+ for (const [route, rule] of Object.entries(routeRules)) {
57
+ if (!rule || typeof rule !== 'object' || rule.swr === false) {
58
+ continue;
59
+ }
60
+
61
+ if ('swr' in rule) {
62
+ const payloadRoute = `${route === '/' ? '' : route.replace(/\/$/, '')}/_payload.json`;
63
+ const current = routeRules[payloadRoute];
64
+ const cache =
65
+ rule.cache && typeof rule.cache === 'object'
66
+ ? rule.cache
67
+ : {
68
+ swr: true,
69
+ ...(typeof rule.swr === 'number'
70
+ ? { maxAge: rule.swr }
71
+ : {}),
72
+ };
73
+
74
+ routeRules[payloadRoute] = {
75
+ ...(current && typeof current === 'object' ? current : {}),
76
+ cache,
77
+ };
78
+ }
79
+ }
80
+ };
81
+
36
82
  const defaultCookieScripts = {
37
83
  necessary: [],
38
84
  analytics: [],
@@ -179,6 +225,10 @@ export default defineNuxtModule<MyelophoneConfig>({
179
225
  if (isStreamingSSR) {
180
226
  nuxt.options.routeRules ||= {};
181
227
  enableDefaultRouteStreaming(nuxt.options.routeRules);
228
+ disablePostRouteStreaming(
229
+ nuxt.options.routeRules,
230
+ myelophone.multi18n.locales,
231
+ );
182
232
  }
183
233
 
184
234
  nuxt.hook('build:before', () => {
@@ -193,10 +243,15 @@ export default defineNuxtModule<MyelophoneConfig>({
193
243
  nuxt.hook('nitro:config', (nitroConfig) => {
194
244
  nitroConfig.runtimeConfig ||= {};
195
245
  nitroConfig.runtimeConfig.myelophone = myelophone;
246
+ nitroConfig.routeRules ||= {};
247
+ addSWRPayloadRoutes(nitroConfig.routeRules);
196
248
 
197
249
  if (isStreamingSSR) {
198
- nitroConfig.routeRules ||= {};
199
250
  enableDefaultRouteStreaming(nitroConfig.routeRules);
251
+ disablePostRouteStreaming(
252
+ nitroConfig.routeRules,
253
+ myelophone.multi18n.locales,
254
+ );
200
255
  }
201
256
  });
202
257
 
@@ -0,0 +1,32 @@
1
+ <script setup lang="ts">
2
+ import { getPost } from "#myelophone/app/utils/blog.server";
3
+
4
+ const route = useRoute();
5
+ const param = "slug" in route.params ? route.params.slug : undefined;
6
+ const slug = Array.isArray(param) ? param.join("/") : (param ?? "");
7
+ const post = await getPost(slug);
8
+
9
+ if (!post) {
10
+ const event = useRequestEvent();
11
+ const isIslandRequest = Boolean(useNuxtApp().ssrContext?.islandContext);
12
+
13
+ if (event && !isIslandRequest) {
14
+ setResponseStatus(event, 404, "Not Found");
15
+ }
16
+ } else {
17
+ useAppSeo({
18
+ title: post.meta.title,
19
+ description: post.meta.description,
20
+ image: post.meta.image,
21
+ });
22
+ }
23
+
24
+ const html = post?.html ?? "";
25
+ </script>
26
+
27
+ <template>
28
+ <div class="post-content-page">
29
+ <ViewNotFound v-if="!post" :set-response-status="false" />
30
+ <article v-else class="post-content" v-html="html" />
31
+ </div>
32
+ </template>
@@ -0,0 +1,36 @@
1
+ export default defineNuxtPlugin(() => {
2
+ const head = injectHead();
3
+
4
+ if (!head) return;
5
+ const hooks = head.hooks;
6
+
7
+ if (!hooks) return;
8
+
9
+ hooks.hook('entries:normalize', ({ tags }) => {
10
+ for (let index = tags.length - 1; index >= 0; index--) {
11
+ const tag = tags[index];
12
+ if (!tag) continue;
13
+
14
+ if (
15
+ tag.tag === 'meta' &&
16
+ tag.props.name?.toLowerCase() === 'twitter:card'
17
+ ) {
18
+ tags.splice(index, 1);
19
+ }
20
+ }
21
+ });
22
+
23
+ hooks.hook('tags:beforeResolve', ({ tags }) => {
24
+ for (let index = tags.length - 1; index >= 0; index--) {
25
+ const tag = tags[index];
26
+ if (!tag) continue;
27
+
28
+ if (
29
+ tag.tag === 'meta' &&
30
+ tag.props.name?.toLowerCase() === 'twitter:card'
31
+ ) {
32
+ tags.splice(index, 1);
33
+ }
34
+ }
35
+ });
36
+ });
@@ -2,7 +2,6 @@ import { type Pinia } from 'pinia';
2
2
 
3
3
  export default defineNuxtPlugin((nuxtApp) => {
4
4
  const router = useRouter();
5
-
6
5
  const pinia = useNuxtApp().$pinia as Pinia;
7
6
  const settingsStore = useSettingsStore(pinia);
8
7
 
@@ -112,6 +112,11 @@ export default defineEventHandler((event) => {
112
112
 
113
113
  for (const [rawKey, rawValue] of Object.entries(rules.headers)) {
114
114
  const key = rawKey as SecurityHeaderKey;
115
+
116
+ if (key === 'contentSecurityPolicy') {
117
+ continue;
118
+ }
119
+
115
120
  const headerName =
116
121
  key === 'contentSecurityPolicy' &&
117
122
  rules.contentSecurityPolicyReportOnly
@@ -9,6 +9,13 @@ export default defineEventHandler((event) => {
9
9
  `http://${event.node.req.headers.host}`,
10
10
  );
11
11
 
12
+ if (
13
+ url.pathname.startsWith('/_nuxt/') ||
14
+ url.pathname.startsWith('/__nuxt_')
15
+ ) {
16
+ return;
17
+ }
18
+
12
19
  const sanitize = (path: string) => {
13
20
  let p = path
14
21
  .replace(/[<>{}|\\^`]/g, '')
@@ -0,0 +1,11 @@
1
+ declare module 'virtual:myelophone-blog-content' {
2
+ const posts: Record<
3
+ string,
4
+ {
5
+ meta: Record<string, string>;
6
+ html: string;
7
+ }
8
+ >;
9
+
10
+ export { posts };
11
+ }
@@ -1,5 +1,4 @@
1
- import { parse } from 'yaml';
2
- import MarkdownIt from 'markdown-it';
1
+ import { posts as generatedPosts } from 'virtual:myelophone-blog-content';
3
2
 
4
3
  export interface PostMeta {
5
4
  title?: string;
@@ -8,135 +7,10 @@ export interface PostMeta {
8
7
  }
9
8
 
10
9
  export interface Post {
11
- content: string;
12
10
  meta: PostMeta;
13
- }
14
-
15
- type PostLoader = () => Promise<string>;
16
-
17
- const postCache = new Map<string, Promise<Post>>();
18
- const markdown = new MarkdownIt({
19
- html: false,
20
- linkify: true,
21
- typographer: true,
22
- });
23
- const blockTagPattern =
24
- /(<\/?(?:address|article|aside|blockquote|div|dl|dt|dd|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|section|table|tbody|td|tfoot|th|thead|tr|ul)\b[^>]*>)\s+(?=<\/?(?:address|article|aside|blockquote|div|dl|dt|dd|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|section|table|tbody|td|tfoot|th|thead|tr|ul)\b)/gi;
25
-
26
- function normalizePosts(
27
- items: Record<string, PostLoader>,
28
- ): Record<string, PostLoader> {
29
- return Object.fromEntries(
30
- Object.entries(items).flatMap(([path, loader]) => {
31
- const normalized = path.replace(/\\/g, '/');
32
-
33
- const marker = '/content/';
34
- const index = normalized.lastIndexOf(marker);
35
- if (index === -1) {
36
- return [];
37
- }
38
-
39
- const slug = normalized
40
- .slice(index + marker.length)
41
- .replace(/\.md$/, '');
42
-
43
- return [[slug, loader]];
44
- }),
45
- );
46
- }
47
-
48
- const postsAlias = import.meta.glob<string>('#myelophone/app/content/**/*.md', {
49
- query: '?raw',
50
- import: 'default',
51
- });
52
-
53
- const postsApp = import.meta.glob<string>('~/content/**/*.md', {
54
- query: '?raw',
55
- import: 'default',
56
- });
57
-
58
- const posts = {
59
- ...normalizePosts(postsAlias),
60
- ...normalizePosts(postsApp),
61
- };
62
-
63
- function parseMarkdown(source: string): Post {
64
- if (!source.startsWith('---')) {
65
- return {
66
- meta: {},
67
- content: source,
68
- };
69
- }
70
-
71
- const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
72
-
73
- if (!match) {
74
- return {
75
- meta: {},
76
- content: source,
77
- };
78
- }
79
-
80
- return {
81
- meta: parsePostMeta(parse(match[1] ?? '')),
82
- content: source.slice(match[0].length),
83
- };
84
- }
85
-
86
- function parsePostMeta(value: unknown): PostMeta {
87
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
88
- return {};
89
- }
90
-
91
- const source = value as Record<string, unknown>;
92
- const meta: PostMeta = {};
93
-
94
- for (const key of ['title', 'description', 'image'] as const) {
95
- if (typeof source[key] === 'string') {
96
- meta[key] = source[key];
97
- }
98
- }
99
-
100
- return meta;
101
- }
102
-
103
- export function renderMarkdown(source: string): string {
104
- return markdown
105
- .render(source)
106
- .split(/(<pre\b[\s\S]*?<\/pre>)/gi)
107
- .map((fragment, index) => {
108
- if (index % 2 === 1) {
109
- return fragment;
110
- }
111
-
112
- return fragment
113
- .replace(/\s*\r?\n\s*/g, ' ')
114
- .replace(blockTagPattern, '$1')
115
- .trim();
116
- })
117
- .join('');
11
+ html: string;
118
12
  }
119
13
 
120
14
  export async function getPost(slug: string): Promise<Post | null> {
121
- const loader = posts[slug];
122
-
123
- if (!loader) {
124
- return null;
125
- }
126
-
127
- const cached = postCache.get(slug);
128
-
129
- if (cached) {
130
- return cached;
131
- }
132
-
133
- const promise = loader().then(parseMarkdown);
134
- postCache.set(slug, promise);
135
-
136
- try {
137
- return await promise;
138
- } catch (error) {
139
- postCache.delete(slug);
140
- throw error;
141
- }
15
+ return generatedPosts[slug] ?? null;
142
16
  }
package/biome.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.5.11/schema.json",
2
+ "$schema": "https://biomejs.dev/schemas/2.5.12/schema.json",
3
3
  "assist": {
4
4
  "actions": {
5
5
  "source": {
package/nuxt.config.ts CHANGED
@@ -10,6 +10,10 @@ import defaultBlog from './app/modules/default-blog';
10
10
  import defaultSitemap from './app/modules/default-sitemap';
11
11
 
12
12
  import { cleanEmptyCssPlugin } from './vite/plugins/clean-css';
13
+ import {
14
+ blogContentPlugin,
15
+ defaultBlogContentDirectories,
16
+ } from './vite/plugins/blog-content';
13
17
  import postcssViewportFallback from './vite/plugins/postcss-viewport-fallback';
14
18
  import noImportant from 'postcss-no-important';
15
19
  import postcssAddViewportUnits from './vite/plugins/postcss-add-viewportunits';
@@ -24,7 +28,7 @@ const pkg = JSON.parse(
24
28
 
25
29
  const version = pkg.version;
26
30
 
27
- const dateActuality = '2026-08-31';
31
+ const dateActuality = '2026-09-05';
28
32
  const banner = `/* © 2025 Aliaksandr Ivanou (https://aleksivanov.me/). All rights reserved. @MyelophOne/Nuxt v${version}. This app bundle licenses: /_nuxt/licenses.md */\n`;
29
33
  const defaultSeo = {
30
34
  title: 'Our Nuxt WebSite | by MyelophOne/Nuxt',
@@ -131,6 +135,10 @@ export default defineNuxtConfig({
131
135
  appConfig: {
132
136
  defaultSeo,
133
137
  },
138
+ seo: {
139
+ automaticTwitterTags: false,
140
+ automaticDefaults: false,
141
+ },
134
142
  ogImage: {
135
143
  enabled: process.env.NODE_ENV !== 'test',
136
144
  zeroRuntime: true,
@@ -184,6 +192,12 @@ export default defineNuxtConfig({
184
192
  },
185
193
  plugins: [
186
194
  tailwindcss(),
195
+ blogContentPlugin(
196
+ defaultBlogContentDirectories(
197
+ process.cwd(),
198
+ path.dirname(fileURLToPath(import.meta.url)),
199
+ ),
200
+ ),
187
201
  {
188
202
  name: 'suppress-plugin-timings-warning',
189
203
  configResolved(config) {
@@ -324,7 +338,7 @@ export default defineNuxtConfig({
324
338
  throwError: true,
325
339
  },
326
340
  rateLimiter: {
327
- tokensPerInterval: 150,
341
+ tokensPerInterval: 300,
328
342
  interval: 300000,
329
343
  headers: true,
330
344
  driver: {
@@ -406,9 +420,6 @@ export default defineNuxtConfig({
406
420
  },
407
421
  '/_ipx/**': { swr: false },
408
422
  },
409
- seo: {
410
- automaticDefaults: false,
411
- },
412
423
  multi18n: {
413
424
  locales: ['en'],
414
425
  defaultLocale: 'en',
@@ -422,29 +433,49 @@ export default defineNuxtConfig({
422
433
  },
423
434
  hooks: {
424
435
  'nitro:config'(nitroConfig) {
425
- if (process.platform !== 'win32') {
426
- return;
436
+ if (process.env.NUXT_PROFILE === '1') {
437
+ nitroConfig.plugins ||= [];
438
+ nitroConfig.plugins.push(
439
+ resolve('./vite/plugins/nitro-profile'),
440
+ );
427
441
  }
428
442
 
429
443
  nitroConfig.rollupConfig ||= {};
430
- const resolver = {
431
- name: 'resolve-nuxt-cache-driver-file-url',
432
- resolveId(source: string) {
433
- if (
434
- source.startsWith('file:') &&
435
- source.endsWith('/runtime/utils/cache-driver.mjs')
436
- ) {
437
- return fileURLToPath(source);
438
- }
439
- },
440
- };
441
- const plugins = nitroConfig.rollupConfig.plugins;
444
+ const plugins = [
445
+ blogContentPlugin(
446
+ defaultBlogContentDirectories(
447
+ process.cwd(),
448
+ path.dirname(fileURLToPath(import.meta.url)),
449
+ ),
450
+ ),
451
+ ];
452
+
453
+ if (process.platform === 'win32') {
454
+ plugins.push({
455
+ name: 'resolve-nuxt-cache-driver-file-url',
456
+ resolveId(source: string) {
457
+ if (
458
+ source.startsWith('file:') &&
459
+ source.endsWith('/runtime/utils/cache-driver.mjs')
460
+ ) {
461
+ return fileURLToPath(source);
462
+ }
463
+ },
464
+ });
465
+ }
442
466
 
443
- nitroConfig.rollupConfig.plugins = plugins
444
- ? Array.isArray(plugins)
445
- ? [...plugins, resolver]
446
- : [plugins, resolver]
447
- : [resolver];
467
+ const currentPlugins = nitroConfig.rollupConfig.plugins;
468
+ const mergedPlugins = [
469
+ ...(Array.isArray(currentPlugins)
470
+ ? currentPlugins
471
+ : currentPlugins
472
+ ? [currentPlugins]
473
+ : []),
474
+ ...plugins,
475
+ ];
476
+ nitroConfig.rollupConfig.plugins = mergedPlugins as NonNullable<
477
+ typeof nitroConfig.rollupConfig.plugins
478
+ >;
448
479
  },
449
480
  'build:manifest': (manifest) => {
450
481
  for (const key in manifest) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myelophone/nuxt",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
4
4
  "type": "module",
5
5
  "author": "Aliaksandr Ivanou <aleksivanov.me@gmail.com> (https://github.com/aleksivanou)",
6
6
  "maintainers": [
@@ -17,6 +17,8 @@
17
17
  "generate": "NODE_OPTIONS='--no-webstorage' NUXT_TELEMETRY_DEBUG=1 NODE_ENV=production NUXT_STATIC=true NUXT_PLAYGROUND=true nuxt generate playground",
18
18
  "preview": "NODE_OPTIONS='--no-webstorage' NUXT_TELEMETRY_DEBUG=1 NUXT_PLAYGROUND=true nuxt preview playground",
19
19
  "server": "node --no-deprecation --no-warnings --no-webstorage playground/.output/server/index.mjs",
20
+ "static": "npx serve playground/.output/public",
21
+ "profile": "NODE_OPTIONS='--no-deprecation --no-webstorage' NUXT_TELEMETRY_DEBUG=1 NODE_ENV=production NUXT_PLAYGROUND=true NUXT_PROFILE=1 nuxt build playground",
20
22
  "prepare": "nuxt prepare && nuxt prepare playground",
21
23
  "analyze": "NODE_OPTIONS='--no-deprecation --no-webstorage' yarn nuxi analyze playground",
22
24
  "test": "yarn test:types && yarn playwright",
@@ -34,7 +36,7 @@
34
36
  "lock-update": "yarn dedupe --strategy highest"
35
37
  },
36
38
  "devDependencies": {
37
- "@biomejs/biome": "^2.5.11",
39
+ "@biomejs/biome": "^2.5.12",
38
40
  "@commitlint/cli": "^21.2.2",
39
41
  "@commitlint/config-conventional": "^21.2.2",
40
42
  "@nuxt/fonts": "^0.14.0",
@@ -44,8 +46,8 @@
44
46
  "@nuxt/types": "^2.18.1",
45
47
  "@nuxtjs/fontaine": "^0.5.0",
46
48
  "@nuxtjs/robots": "^6.2.0",
47
- "@pinia/colada": "^1.4.2",
48
- "@pinia/colada-nuxt": "^1.0.2",
49
+ "@pinia/colada": "^1.4.4",
50
+ "@pinia/colada-nuxt": "^1.1.0",
49
51
  "@pinia/nuxt": "^1.0.2",
50
52
  "@playwright/test": "^1.62.1",
51
53
  "@semantic-release/changelog": "^7.0.0",
@@ -55,7 +57,7 @@
55
57
  "@semantic-release/npm": "^13.1.5",
56
58
  "@semantic-release/release-notes-generator": "^14.1.1",
57
59
  "@tailwindcss/vite": "^4.3.3",
58
- "@types/node": "^26.4.0",
60
+ "@types/node": "^26.4.1",
59
61
  "@unhead/vue": "^3.4.0",
60
62
  "@vue/devtools-api": "^8.2.1",
61
63
  "@vueuse/core": "^14.4.0",
@@ -68,13 +70,13 @@
68
70
  "nuxt-og-image": "^6.7.8",
69
71
  "nuxt-payload-analyzer": "^0.1.4",
70
72
  "nuxt-security": "^2.6.0",
71
- "nuxt-seo-utils": "^8.4.3",
73
+ "nuxt-seo-utils": "^8.5.0",
72
74
  "nuxt-vitalizer": "^3.0.0",
73
75
  "pinia": "^4.0.3",
74
- "postcss": "^8.5.26",
76
+ "postcss": "^8.5.27",
75
77
  "postcss-no-important": "^11.0.0",
76
78
  "purgecss": "^8.0.0",
77
- "rolldown": "^1.2.6",
79
+ "rolldown": "^1.2.7",
78
80
  "semantic-release": "^25.0.9",
79
81
  "tailwindcss": "^4.3.3",
80
82
  "typescript": "^7.0.2",
@@ -0,0 +1,126 @@
1
+ import { readdirSync, readFileSync } from 'node:fs';
2
+ import { extname, join, relative, resolve } from 'node:path';
3
+ import type { Plugin } from 'vite';
4
+ import { parse } from 'yaml';
5
+ import MarkdownIt, { type MarkdownIt as MarkdownItInstance } from 'markdown-it';
6
+
7
+ const VIRTUAL_MODULE_ID = 'virtual:myelophone-blog-content';
8
+ const RESOLVED_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
9
+ const blockTagPattern =
10
+ /(<\/?(?:address|article|aside|blockquote|div|dl|dt|dd|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|section|table|tbody|td|tfoot|th|thead|tr|ul)\b[^>]*>)\s+(?=<\/?(?:address|article|aside|blockquote|div|dl|dt|dd|fieldset|figcaption|figure|footer|form|h[1-6]|header|hr|li|main|nav|ol|p|section|table|tbody|td|tfoot|th|thead|tr|ul)\b)/gi;
11
+
12
+ interface GeneratedPost {
13
+ meta: Record<string, string>;
14
+ html: string;
15
+ }
16
+
17
+ function collectMarkdownFiles(directory: string): string[] {
18
+ try {
19
+ return readdirSync(directory, { withFileTypes: true }).flatMap(
20
+ (entry) => {
21
+ const file = join(directory, entry.name);
22
+ return entry.isDirectory()
23
+ ? collectMarkdownFiles(file)
24
+ : extname(file).toLowerCase() === '.md'
25
+ ? [file]
26
+ : [];
27
+ },
28
+ );
29
+ } catch {
30
+ return [];
31
+ }
32
+ }
33
+
34
+ function renderMarkdown(source: string, markdown: MarkdownItInstance): string {
35
+ return markdown
36
+ .render(source)
37
+ .split(/(<pre\b[\s\S]*?<\/pre>)/gi)
38
+ .map((fragment: string, index: number) => {
39
+ if (index % 2 === 1) {
40
+ return fragment;
41
+ }
42
+
43
+ return fragment
44
+ .replace(/\s*\r?\n\s*/g, ' ')
45
+ .replace(blockTagPattern, '$1')
46
+ .trim();
47
+ })
48
+ .join('');
49
+ }
50
+
51
+ function parsePost(
52
+ source: string,
53
+ markdown: MarkdownItInstance,
54
+ ): GeneratedPost {
55
+ let meta: Record<string, string> = {};
56
+ let content = source;
57
+
58
+ if (source.startsWith('---')) {
59
+ const match = source.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
60
+
61
+ if (match) {
62
+ const parsed = parse(match[1] ?? '');
63
+ if (
64
+ parsed &&
65
+ typeof parsed === 'object' &&
66
+ !Array.isArray(parsed)
67
+ ) {
68
+ for (const key of ['title', 'description', 'image']) {
69
+ const value = (parsed as Record<string, unknown>)[key];
70
+ if (typeof value === 'string') {
71
+ meta[key] = value;
72
+ }
73
+ }
74
+ }
75
+ content = source.slice(match[0].length);
76
+ }
77
+ }
78
+
79
+ return { meta, html: renderMarkdown(content, markdown) };
80
+ }
81
+
82
+ function generatePosts(directories: string[]): Record<string, GeneratedPost> {
83
+ const markdown = new MarkdownIt({
84
+ html: false,
85
+ linkify: true,
86
+ typographer: true,
87
+ });
88
+ const posts: Record<string, GeneratedPost> = {};
89
+
90
+ for (const directory of directories) {
91
+ for (const file of collectMarkdownFiles(directory)) {
92
+ const slug = relative(directory, file)
93
+ .replace(/\\/g, '/')
94
+ .replace(/\.md$/i, '');
95
+ posts[slug] = parsePost(readFileSync(file, 'utf8'), markdown);
96
+ }
97
+ }
98
+
99
+ return posts;
100
+ }
101
+
102
+ export function blogContentPlugin(directories: string[]): Plugin {
103
+ return {
104
+ name: 'myelophone-blog-content',
105
+ resolveId(id) {
106
+ return id === VIRTUAL_MODULE_ID ? RESOLVED_MODULE_ID : undefined;
107
+ },
108
+ load(id) {
109
+ if (id !== RESOLVED_MODULE_ID) {
110
+ return undefined;
111
+ }
112
+
113
+ return `export const posts = ${JSON.stringify(generatePosts(directories))};`;
114
+ },
115
+ };
116
+ }
117
+
118
+ export const defaultBlogContentDirectories = (
119
+ projectRoot: string,
120
+ packageRoot = projectRoot,
121
+ ) => [
122
+ resolve(packageRoot, 'app/content'),
123
+ resolve(projectRoot, 'app/content'),
124
+ resolve(projectRoot, 'content'),
125
+ resolve(projectRoot, 'playground/content'),
126
+ ];
@@ -10,6 +10,21 @@ interface CleanCssPluginOptions {
10
10
  purgeTailwindUtilities?: boolean;
11
11
  }
12
12
 
13
+ function formatBytes(bytes: number): string {
14
+ if (bytes === 0) {
15
+ return '0 B';
16
+ }
17
+
18
+ const units = ['B', 'KB', 'MB', 'GB'];
19
+ const unitIndex = Math.min(
20
+ Math.floor(Math.log(bytes) / Math.log(1024)),
21
+ units.length - 1,
22
+ );
23
+ const value = bytes / 1024 ** unitIndex;
24
+
25
+ return `${parseFloat(value.toFixed(2))} ${units[unitIndex]}`;
26
+ }
27
+
13
28
  export function cleanEmptyCssPlugin(
14
29
  options: CleanCssPluginOptions = {},
15
30
  ): Plugin {
@@ -175,12 +190,16 @@ export function cleanEmptyCssPlugin(
175
190
  }
176
191
 
177
192
  await writeFile(file, css);
193
+ const sizeAfter = Buffer.byteLength(css, 'utf8');
178
194
  reports.push(
179
- `.../${basename(file)} (${sizeBefore} B → ${Buffer.byteLength(css, 'utf8')} B)`,
195
+ `.../${basename(file)} (${formatBytes(sizeBefore)} → ${formatBytes(sizeAfter)})`,
180
196
  );
181
197
  }
182
198
  },
183
199
  closeBundle() {
200
+ if (this.environment?.name !== 'client') {
201
+ return;
202
+ }
184
203
  logger.info('Purging css...');
185
204
  for (const report of reports) {
186
205
  logger.info(report);
@@ -0,0 +1,86 @@
1
+ interface TimingState {
2
+ requestStart: number;
3
+ renderStart?: number;
4
+ renderEnd?: number;
5
+ }
6
+
7
+ export default defineNitroPlugin((nitroApp) => {
8
+ const timings = new WeakMap<object, TimingState>();
9
+
10
+ const getTiming = (event: { context: object }) =>
11
+ timings.get(event.context);
12
+
13
+ const writeTiming = (event: any) => {
14
+ const timing = getTiming(event);
15
+ if (!timing) {
16
+ return;
17
+ }
18
+
19
+ const now = performance.now();
20
+ const serverTiming: string[] = [];
21
+
22
+ if (timing.renderStart) {
23
+ serverTiming.push(
24
+ `nuxt-pre-render;dur=${(timing.renderStart - timing.requestStart).toFixed(2)}`,
25
+ );
26
+ }
27
+
28
+ if (timing.renderStart && timing.renderEnd) {
29
+ serverTiming.push(
30
+ `nuxt-render;dur=${(timing.renderEnd - timing.renderStart).toFixed(2)}`,
31
+ );
32
+ }
33
+
34
+ if (timing.renderEnd) {
35
+ serverTiming.push(
36
+ `nuxt-post-render;dur=${(now - timing.renderEnd).toFixed(2)}`,
37
+ );
38
+ }
39
+
40
+ const cache = event.context.cache;
41
+ if (cache) {
42
+ serverTiming.push(
43
+ `nuxt-cache;desc="${timing.renderStart ? 'miss' : 'hit'}"`,
44
+ );
45
+ }
46
+
47
+ serverTiming.push(
48
+ `nuxt-total;dur=${(now - timing.requestStart).toFixed(2)}`,
49
+ );
50
+
51
+ setResponseHeader(event, 'Server-Timing', serverTiming.join(', '));
52
+ };
53
+
54
+ nitroApp.hooks.hook('request', (event) => {
55
+ timings.set(event.context, { requestStart: performance.now() });
56
+
57
+ const response = event.node?.res;
58
+ if (!response) {
59
+ return;
60
+ }
61
+
62
+ const end = response.end.bind(response);
63
+ response.end = ((...args: Parameters<typeof response.end>) => {
64
+ writeTiming(event);
65
+ return end(...args);
66
+ }) as typeof response.end;
67
+ });
68
+
69
+ nitroApp.hooks.hook('render:before', ({ event }) => {
70
+ const timing = getTiming(event);
71
+ if (timing) {
72
+ timing.renderStart = performance.now();
73
+ }
74
+ });
75
+
76
+ nitroApp.hooks.hook('render:html', (_html, { event }) => {
77
+ const timing = getTiming(event);
78
+ if (timing) {
79
+ timing.renderEnd = performance.now();
80
+ }
81
+ });
82
+
83
+ nitroApp.hooks.hook('beforeResponse', (event) => {
84
+ writeTiming(event);
85
+ });
86
+ });
@@ -1,23 +0,0 @@
1
- <script setup lang="ts">
2
- import { getPost, renderMarkdown } from "#myelophone/app/utils/blog.server";
3
-
4
- const props = defineProps<{
5
- slug: string;
6
- }>();
7
-
8
- const post = await getPost(props.slug);
9
- const html = post ? renderMarkdown(post.content) : "";
10
-
11
- if (post) {
12
- useAppSeo({
13
- title: post.meta.title,
14
- description: post.meta.description,
15
- image: post.meta.image,
16
- });
17
- }
18
- </script>
19
-
20
- <template>
21
- <ViewNotFound v-if="!post" :set-response-status="false" />
22
- <article v-else class="post-content" v-html="html" />
23
- </template>
@@ -1,30 +0,0 @@
1
- <script setup lang="ts">
2
- const route = useRoute();
3
-
4
- const param = "slug" in route.params ? route.params.slug : undefined;
5
-
6
- const slug = Array.isArray(param) ? param.join("/") : (param ?? "");
7
-
8
- if (import.meta.server) {
9
- const { getPost } = await import("#myelophone/app/utils/blog.server");
10
- const post = await getPost(slug);
11
-
12
- if (!post) {
13
- const event = useRequestEvent();
14
-
15
- if (event) {
16
- setResponseStatus(event, 404);
17
- }
18
- } else {
19
- useAppSeo({
20
- title: post.meta.title,
21
- description: post.meta.description,
22
- image: post.meta.image,
23
- });
24
- }
25
- }
26
- </script>
27
-
28
- <template>
29
- <UiPostContent :slug="slug" />
30
- </template>