create-eziwiki 0.1.0 → 0.1.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-eziwiki",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Scaffold a new eziwiki documentation site",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,15 +9,19 @@
9
9
  @import '../styles/markdown.css';
10
10
 
11
11
  /*
12
- * `@font-face` for Pretendard and SUITE is declared in `app/layout.tsx`, not
13
- * here: the font URLs need the deployment base path prefixed, which a
14
- * stylesheet cannot read. See FONT_FACES there.
12
+ * `@font-face` for Pretendard is declared in `app/layout.tsx`, not here: the
13
+ * font URLs need the deployment base path prefixed, which a stylesheet cannot
14
+ * read. See FONT_FACES there, which also splits each weight by `unicode-range`
15
+ * so a page fetches only the character sets it uses.
15
16
  */
16
17
 
17
18
  body {
19
+ /* SUITE used to sit between Pretendard and the system stack. Pretendard
20
+ covers Latin and Korean, so nothing ever reached it except the emoji in
21
+ section names — which it has no glyphs for either, and which the platform
22
+ emoji font renders. It was 274 kB nobody saw. */
18
23
  font-family:
19
24
  'Pretendard',
20
- 'SUITE',
21
25
  -apple-system,
22
26
  BlinkMacSystemFont,
23
27
  'Segoe UI',
@@ -74,6 +74,23 @@ export const metadata: Metadata = {
74
74
  : undefined,
75
75
  };
76
76
 
77
+ /**
78
+ * Character ranges each font file covers.
79
+ *
80
+ * Pretendard carries the full Korean syllabary, which is most of its weight:
81
+ * one weight is 750 kB whole, 27 kB once the Hangul is taken out. Splitting it
82
+ * and declaring what each half covers lets the browser fetch only the halves a
83
+ * page actually uses — an English page never asks for the Korean file, and a
84
+ * Korean one pays for it once and keeps it, since `public/` is served
85
+ * immutable.
86
+ */
87
+ const RANGES = {
88
+ latin:
89
+ 'U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+2000-206F,' +
90
+ 'U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD',
91
+ korean: 'U+1100-11FF,U+3000-303F,U+3130-318F,U+A960-A97F,U+AC00-D7A3,U+D7B0-D7FF,U+FF00-FFEF',
92
+ } as const;
93
+
77
94
  /**
78
95
  * Web fonts, declared here rather than in `globals.css`.
79
96
  *
@@ -83,31 +100,34 @@ export const metadata: Metadata = {
83
100
  * the declarations from here puts them through `asset()` like every other file
84
101
  * in `public/`.
85
102
  *
86
- * `preload` marks the weights the first paint needs; the rest load on demand.
103
+ * Only the Latin halves are preloaded. Preloading the Korean ones would undo
104
+ * the split by fetching them before anything has asked for a Korean glyph.
87
105
  */
88
- const FONT_FACES = [
89
- { family: 'SUITE', weight: 400, file: '/fonts/SUITE/SUITE-Regular.woff2' },
90
- { family: 'SUITE', weight: 600, file: '/fonts/SUITE/SUITE-SemiBold.woff2' },
91
- { family: 'SUITE', weight: 700, file: '/fonts/SUITE/SUITE-Bold.woff2' },
92
- {
93
- family: 'Pretendard',
94
- weight: 400,
95
- file: '/fonts/Pretandard/Pretendard-Regular.woff2',
96
- preload: true,
97
- },
98
- {
99
- family: 'Pretendard',
100
- weight: 600,
101
- file: '/fonts/Pretandard/Pretendard-SemiBold.woff2',
102
- preload: true,
103
- },
104
- { family: 'Pretendard', weight: 700, file: '/fonts/Pretandard/Pretendard-Bold.woff2' },
106
+ interface FontFace {
107
+ weight: number;
108
+ subset: keyof typeof RANGES;
109
+ preload?: boolean;
110
+ }
111
+
112
+ const FONT_FACES: FontFace[] = [
113
+ { weight: 400, subset: 'latin', preload: true },
114
+ { weight: 400, subset: 'korean' },
115
+ { weight: 600, subset: 'latin', preload: true },
116
+ { weight: 600, subset: 'korean' },
117
+ { weight: 700, subset: 'latin' },
118
+ { weight: 700, subset: 'korean' },
105
119
  ];
106
120
 
121
+ /** Where a weight-and-subset pair is served from. */
122
+ function fontFile({ weight, subset }: FontFace): string {
123
+ return `/fonts/Pretendard/pretendard-${weight}-${subset}.woff2`;
124
+ }
125
+
107
126
  const fontFaceCss = FONT_FACES.map(
108
- ({ family, weight, file }) =>
109
- `@font-face{font-family:'${family}';font-weight:${weight};` +
110
- `src:url('${asset(file)}') format('woff2');font-display:swap}`,
127
+ (font) =>
128
+ `@font-face{font-family:'Pretendard';font-weight:${font.weight};` +
129
+ `src:url('${asset(fontFile(font))}') format('woff2');font-display:swap;` +
130
+ `unicode-range:${RANGES[font.subset]}}`,
111
131
  ).join('');
112
132
 
113
133
  export default function RootLayout({ children }: { children: React.ReactNode }) {
@@ -119,9 +139,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
119
139
  <head>
120
140
  {FONT_FACES.filter((font) => font.preload).map((font) => (
121
141
  <link
122
- key={font.file}
142
+ key={fontFile(font)}
123
143
  rel="preload"
124
- href={asset(font.file)}
144
+ href={asset(fontFile(font))}
125
145
  as="font"
126
146
  type="font/woff2"
127
147
  crossOrigin="anonymous"
@@ -3,8 +3,17 @@ import { payload } from '@/payload/config';
3
3
  import { fileUrl } from '@/lib/basePath';
4
4
 
5
5
  /**
6
- * Generate robots.txt file
7
- * Controls how search engines crawl and index the site
6
+ * Generates robots.txt.
7
+ *
8
+ * Nothing is disallowed. Search engines render a page before judging it, so
9
+ * blocking `/_next/` — where the stylesheet and scripts live — leaves the
10
+ * crawler looking at an unstyled document and reporting the assets as blocked
11
+ * resources. There is no `/api/` either: the site is a static export.
12
+ *
13
+ * Pages that should stay out of the index carry `noindex` in their own
14
+ * metadata, which is the mechanism that actually keeps them out. A path
15
+ * disallowed here would still be indexable if something linked to it, since a
16
+ * crawler that is told not to fetch a page cannot read the `noindex` on it.
8
17
  *
9
18
  * @returns Robots configuration
10
19
  */
@@ -13,7 +22,6 @@ export default function robots(): MetadataRoute.Robots {
13
22
  rules: {
14
23
  userAgent: '*',
15
24
  allow: '/',
16
- disallow: ['/api/', '/_next/'],
17
25
  },
18
26
  sitemap: fileUrl('/sitemap.xml', payload.global.baseUrl),
19
27
  };
@@ -22,17 +22,24 @@ export function SearchTrigger({ className = '' }: { className?: string }) {
22
22
  }, []);
23
23
 
24
24
  return (
25
+ // No aria-label: it read "Search documentation" while the control visibly
26
+ // says "Search…", so the spoken name did not contain the written one and
27
+ // voice control had nothing to match. The text names the button instead;
28
+ // the icon and the shortcut are hidden from the name, the latter because
29
+ // `aria-keyshortcuts` already announces it.
25
30
  <button
26
31
  type="button"
27
32
  onClick={open}
28
- aria-label="Search documentation"
29
33
  aria-keyshortcuts="Meta+K Control+K"
30
- className={`flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-400 transition-colors hover:border-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-gray-600 ${className}`}
34
+ className={`flex items-center gap-2 rounded-md border border-gray-300 bg-white px-2 py-1 text-sm text-gray-500 transition-colors hover:border-gray-400 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-gray-600 ${className}`}
31
35
  >
32
- <Search className="h-4 w-4 flex-shrink-0" />
36
+ <Search className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
33
37
  <span className="min-w-0 flex-1 truncate text-left">Search…</span>
34
38
  {shortcut && (
35
- <kbd className="hidden flex-shrink-0 rounded border border-gray-300 px-1 py-0.5 text-[10px] text-gray-500 sm:block dark:border-gray-600 dark:text-gray-400">
39
+ <kbd
40
+ aria-hidden="true"
41
+ className="hidden flex-shrink-0 rounded border border-gray-300 px-1 py-0.5 text-[10px] text-gray-500 sm:block dark:border-gray-600 dark:text-gray-400"
42
+ >
36
43
  {shortcut}
37
44
  </kbd>
38
45
  )}
@@ -221,15 +221,32 @@ export function rehypeBasePath(basePath: string) {
221
221
  }
222
222
 
223
223
  /**
224
- * Adds lazy loading and consistent styling hooks to content images.
224
+ * Adds loading hints and consistent styling hooks to content images.
225
+ *
226
+ * Every image was deferred, including the first one. On a page that opens with
227
+ * a figure that image is what the browser measures as the largest contentful
228
+ * paint, and `loading="lazy"` keeps it out of the preload scan — the request
229
+ * only starts once layout has reached it, so the headline metric waits on a
230
+ * round trip that need not have been late. The first image is therefore
231
+ * fetched eagerly and marked high priority; the rest, which are further down,
232
+ * keep deferring.
225
233
  */
226
234
  export function rehypeImages() {
227
235
  return (tree: Root) => {
236
+ let seen = 0;
237
+
228
238
  visit(tree, 'element', (node: Element) => {
229
239
  if (node.tagName !== 'img') return;
230
240
 
231
241
  node.properties ??= {};
232
- node.properties.loading ??= 'lazy';
242
+ const isFirst = seen++ === 0;
243
+
244
+ if (isFirst) {
245
+ node.properties.loading ??= 'eager';
246
+ node.properties.fetchPriority ??= 'high';
247
+ } else {
248
+ node.properties.loading ??= 'lazy';
249
+ }
233
250
  node.properties.decoding ??= 'async';
234
251
 
235
252
  const className = node.properties.className;
@@ -43,14 +43,32 @@ export interface RenderedMarkdown {
43
43
  headings: Heading[];
44
44
  }
45
45
 
46
- /** Syntax highlighting themes, applied as CSS variables for light and dark. */
47
- const SHIKI_THEMES = { light: 'github-light', dark: 'github-dark' } as const;
46
+ /**
47
+ * Syntax highlighting themes, applied as CSS variables for light and dark.
48
+ *
49
+ * The high-contrast variants, because the plain ones are tuned for GitHub's
50
+ * near-white code background: against the slightly darker grey used here their
51
+ * strings, keywords and comments land between 4.15:1 and 4.37:1, under the
52
+ * 4.5:1 that body-sized text needs. Lightening the background would fix the
53
+ * ratios too, but a code block that matches the page around it stops reading
54
+ * as a code block.
55
+ */
56
+ const SHIKI_THEMES = {
57
+ light: 'github-light-high-contrast',
58
+ dark: 'github-dark-high-contrast',
59
+ } as const;
48
60
 
49
61
  let processor: Processor | null = null;
50
62
 
51
63
  /**
52
64
  * Resolves a wiki-link target to a destination in this site.
53
65
  *
66
+ * The trailing slash is applied here rather than downstream. Under the `path`
67
+ * strategy `rehypeInternalLinks` sees a content path it can resolve and adds
68
+ * one, but under `hash` the URL is already a digest, which does not map back
69
+ * to a document — so the link was left without it and every wiki link cost a
70
+ * redirect.
71
+ *
54
72
  * @param target - Raw target text from inside the brackets
55
73
  * @returns The destination, or null when the target does not resolve
56
74
  */
@@ -59,7 +77,7 @@ function resolveWikiLink(target: string): WikiLinkTarget | null {
59
77
  if (!doc) return null;
60
78
 
61
79
  const url = docPathToUrl(getUrlMap(), doc.path);
62
- return url ? { url: `/${url}`, title: doc.title } : null;
80
+ return url ? { url: `/${url}/`, title: doc.title } : null;
63
81
  }
64
82
 
65
83
  /**
@@ -31,14 +31,14 @@
31
31
  font-size: 0.75rem;
32
32
  text-transform: uppercase;
33
33
  letter-spacing: 0.03em;
34
- color: var(--color-text-muted);
34
+ color: var(--color-code-muted);
35
35
  }
36
36
 
37
37
  .ezw-code__copy {
38
38
  font-size: 0.75rem;
39
39
  padding: 0.25rem 0.5rem;
40
40
  border-radius: 0.25rem;
41
- color: var(--color-text-muted);
41
+ color: var(--color-code-muted);
42
42
  background: transparent;
43
43
  transition:
44
44
  color 0.15s ease,
@@ -28,6 +28,11 @@
28
28
  /* Code block colors */
29
29
  --color-code-bg: #f3f4f6;
30
30
  --color-code-text: #1f2937;
31
+ /* Secondary text inside a code block. Darker than --color-text-muted, which
32
+ reaches only 4.39:1 against the code background and so falls short of the
33
+ 4.5:1 that the small type used for the language label and the copy button
34
+ needs. */
35
+ --color-code-muted: #4b5563;
31
36
 
32
37
  /* Border colors */
33
38
  --color-border: #e5e7eb;
@@ -59,6 +64,8 @@
59
64
  /* Code block colors */
60
65
  --color-code-bg: #374151;
61
66
  --color-code-text: #f9fafb;
67
+ /* See the light theme: the muted grey reaches only 4.06:1 here. */
68
+ --color-code-muted: #d1d5db;
62
69
 
63
70
  /* Border colors */
64
71
  --color-border: #374151;
@@ -0,0 +1,31 @@
1
+ {
2
+ "$schema": "https://openapi.vercel.sh/vercel.json",
3
+ "headers": [
4
+ {
5
+ "source": "/fonts/(.*)",
6
+ "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
7
+ },
8
+ {
9
+ "source": "/images/(.*)",
10
+ "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }]
11
+ },
12
+ {
13
+ "source": "/(search-index.json|favicon.svg|og-image.svg)",
14
+ "headers": [
15
+ {
16
+ "key": "Cache-Control",
17
+ "value": "public, max-age=0, s-maxage=86400, stale-while-revalidate=604800"
18
+ }
19
+ ]
20
+ },
21
+ {
22
+ "source": "/(.*)",
23
+ "headers": [
24
+ { "key": "X-Content-Type-Options", "value": "nosniff" },
25
+ { "key": "X-Frame-Options", "value": "SAMEORIGIN" },
26
+ { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
27
+ { "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" }
28
+ ]
29
+ }
30
+ ]
31
+ }