@umami/shiso 0.54.0 → 0.61.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.
Files changed (99) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +9 -49
  3. package/bin/shiso.mjs +132 -0
  4. package/docs.schema.json +835 -0
  5. package/mdx.config.ts +143 -0
  6. package/package.json +73 -83
  7. package/scripts/check-package.mjs +18 -0
  8. package/scripts/generate-icon-registry.mjs +196 -0
  9. package/scripts/generate-last-modified.mjs +128 -0
  10. package/scripts/generate-search-index.mjs +252 -0
  11. package/scripts/load-docs-config.mjs +244 -0
  12. package/scripts/prerender.mjs +187 -0
  13. package/scripts/validate-config.mjs +104 -0
  14. package/scripts/vite-docs-config.mjs +60 -0
  15. package/src/App.tsx +39 -0
  16. package/src/components/Banner.tsx +69 -0
  17. package/src/components/CodeBlock.tsx +46 -0
  18. package/src/components/ConfiguredIcon.tsx +15 -0
  19. package/src/components/ContextualMenu.tsx +93 -0
  20. package/src/components/DocContent.tsx +100 -0
  21. package/src/components/Docs.tsx +126 -0
  22. package/src/components/Footer.tsx +84 -0
  23. package/src/components/Header.tsx +72 -0
  24. package/src/components/Layout.tsx +16 -0
  25. package/src/components/PageLinks.tsx +71 -0
  26. package/src/components/Search.tsx +208 -0
  27. package/src/components/SideNav.tsx +347 -0
  28. package/src/components/SocialIcon.tsx +88 -0
  29. package/src/components/ThemeToggle.tsx +34 -0
  30. package/src/components/TopNav.tsx +127 -0
  31. package/src/components/docs/Accordion.tsx +68 -0
  32. package/src/components/docs/Badge.tsx +171 -0
  33. package/src/components/docs/Callout.tsx +73 -0
  34. package/src/components/docs/Card.tsx +158 -0
  35. package/src/components/docs/CodeGroup.tsx +73 -0
  36. package/src/components/docs/Columns.tsx +20 -0
  37. package/src/components/docs/Expandable.tsx +28 -0
  38. package/src/components/docs/Frame.tsx +56 -0
  39. package/src/components/docs/Icon.tsx +30 -0
  40. package/src/components/docs/ParamField.tsx +45 -0
  41. package/src/components/docs/PropertiesTable.tsx +84 -0
  42. package/src/components/docs/ResponseField.tsx +36 -0
  43. package/src/components/docs/Steps.tsx +47 -0
  44. package/src/components/docs/Tabs.tsx +116 -0
  45. package/src/components/docs/Tooltip.tsx +21 -0
  46. package/src/components/docs/index.ts +15 -0
  47. package/src/components/docs/styles.ts +82 -0
  48. package/src/components/docs/utils.ts +118 -0
  49. package/src/components/icons/index.ts +17 -0
  50. package/src/components/ui/accordion.tsx +69 -0
  51. package/src/components/ui/alert.tsx +69 -0
  52. package/src/components/ui/badge.tsx +49 -0
  53. package/src/components/ui/button.tsx +58 -0
  54. package/src/components/ui/card.tsx +88 -0
  55. package/src/components/ui/collapsible.tsx +15 -0
  56. package/src/components/ui/command.tsx +173 -0
  57. package/src/components/ui/dialog.tsx +137 -0
  58. package/src/components/ui/dropdown-menu.tsx +257 -0
  59. package/src/components/ui/scroll-area.tsx +71 -0
  60. package/src/components/ui/sheet.tsx +124 -0
  61. package/src/components/ui/tabs.tsx +73 -0
  62. package/src/components/ui/tooltip.tsx +52 -0
  63. package/src/declarations.d.ts +9 -0
  64. package/src/entry-client.tsx +17 -0
  65. package/src/entry-server.tsx +77 -0
  66. package/src/generated/last-modified.ts +2 -0
  67. package/src/lib/content.ts +44 -0
  68. package/src/lib/docs-config.ts +682 -0
  69. package/src/lib/head.ts +220 -0
  70. package/src/lib/icon-registry.generated.ts +4 -0
  71. package/src/lib/icons.ts +29 -0
  72. package/src/lib/inline-markdown.tsx +86 -0
  73. package/src/lib/mdast.ts +56 -0
  74. package/src/lib/paths.ts +86 -0
  75. package/src/lib/remark-toc.ts +71 -0
  76. package/src/lib/search/config.ts +43 -0
  77. package/src/lib/search/provider.ts +80 -0
  78. package/src/lib/search/providers/local.ts +15 -0
  79. package/src/lib/search-index.generated.ts +4 -0
  80. package/src/lib/search.ts +100 -0
  81. package/src/lib/site-config.ts +104 -0
  82. package/src/lib/site-model.ts +221 -0
  83. package/src/lib/slug.ts +38 -0
  84. package/src/lib/types.ts +478 -0
  85. package/src/lib/utils.ts +6 -0
  86. package/src/pages/DocPage.tsx +33 -0
  87. package/src/styles/global.css +268 -0
  88. package/src/styles/tokens.css +114 -0
  89. package/types/client.d.ts +3 -0
  90. package/types/search.d.ts +17 -0
  91. package/vite.config.ts +342 -0
  92. package/LICENSE +0 -21
  93. package/dist/index.css +0 -189
  94. package/dist/index.d.ts +0 -57
  95. package/dist/index.js +0 -464
  96. package/dist/index.mjs +0 -437
  97. package/server/index.d.ts +0 -30
  98. package/server/index.js +0 -189
  99. package/styles.css +0 -4766
package/mdx.config.ts ADDED
@@ -0,0 +1,143 @@
1
+ import mdx from '@mdx-js/rollup';
2
+ import rehypeAutolinkHeadings from 'rehype-autolink-headings';
3
+ import rehypeHighlight from 'rehype-highlight';
4
+ import rehypeSlug from 'rehype-slug';
5
+ import remarkFrontmatter from 'remark-frontmatter';
6
+ import remarkGfm from 'remark-gfm';
7
+ import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
8
+ import type { Plugin } from 'vite';
9
+ import { type MdNode, toText, walkTree } from './src/lib/mdast.ts';
10
+ import { remarkToc } from './src/lib/remark-toc.ts';
11
+
12
+ const SYNTHETIC_FRAGMENT = 'data-shiso-synthetic-fragment';
13
+
14
+ function remarkCodeTitles() {
15
+ return (tree: MdNode) => {
16
+ const visit = (node: MdNode) => {
17
+ if (node.type === 'code' && node.meta?.trim()) {
18
+ const meta = node.meta.trim();
19
+ const titleMatch = meta.match(/(?:^|\s)title=(?:"([^"]+)"|'([^']+)'|([^\s]+))/);
20
+ const title = titleMatch ? titleMatch[1] || titleMatch[2] || titleMatch[3] : meta;
21
+ const hProperties = (node.data?.hProperties || {}) as Record<string, unknown>;
22
+ node.data = {
23
+ ...node.data,
24
+ hProperties: { ...hProperties, 'data-title': title },
25
+ };
26
+ }
27
+
28
+ node.children?.forEach(visit);
29
+ };
30
+
31
+ visit(tree);
32
+ };
33
+ }
34
+
35
+ function rehypeWrapJsxForHighlighting() {
36
+ return (tree: MdNode) => {
37
+ walkTree(tree, node => {
38
+ if (node.tagName !== 'code') {
39
+ return;
40
+ }
41
+
42
+ const properties = (node.properties || {}) as Record<string, unknown>;
43
+ const className = properties.className;
44
+ const classes = Array.isArray(className) ? className.map(String) : [String(className || '')];
45
+ const source = toText(node);
46
+
47
+ if (!classes.some(value => /\blanguage-(?:jsx|tsx)\b/.test(value))) {
48
+ return;
49
+ }
50
+
51
+ if (!/^\s*<[A-Z][\w.]*(?:\s|>|\/)/.test(source)) {
52
+ return;
53
+ }
54
+
55
+ properties[SYNTHETIC_FRAGMENT] = true;
56
+ node.properties = properties;
57
+ node.children = [{ type: 'text', value: `<>\n${source}\n</>` }];
58
+ });
59
+ };
60
+ }
61
+
62
+ function boundaryText(node: MdNode, fromEnd = false): MdNode | undefined {
63
+ if (node.type === 'text' && typeof node.value === 'string') {
64
+ return node;
65
+ }
66
+
67
+ const children = fromEnd ? [...(node.children || [])].reverse() : node.children || [];
68
+ for (const child of children) {
69
+ const match = boundaryText(child, fromEnd);
70
+ if (match) {
71
+ return match;
72
+ }
73
+ }
74
+
75
+ return undefined;
76
+ }
77
+
78
+ function rehypeRemoveHighlightingFragments() {
79
+ return (tree: MdNode) => {
80
+ walkTree(tree, node => {
81
+ if (node.tagName !== 'code') {
82
+ return;
83
+ }
84
+
85
+ const properties = (node.properties || {}) as Record<string, unknown>;
86
+ if (!properties[SYNTHETIC_FRAGMENT]) {
87
+ return;
88
+ }
89
+
90
+ const first = boundaryText(node);
91
+ const last = boundaryText(node, true);
92
+ if (first?.value) {
93
+ first.value = first.value.replace(/^<>\r?\n/, '');
94
+ }
95
+ if (last?.value) {
96
+ last.value = last.value.replace(/\r?\n<\/>$/, '');
97
+ }
98
+
99
+ delete properties[SYNTHETIC_FRAGMENT];
100
+ });
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The MDX compilation pipeline, shared by the app build and the test runner so
106
+ * tests exercise the same transforms the site ships with.
107
+ */
108
+ export function shisoMdx(): Plugin {
109
+ return {
110
+ // Must run before vite:react-babel so MDX is compiled to JSX first.
111
+ enforce: 'pre',
112
+ ...mdx({
113
+ providerImportSource: '@mdx-js/react',
114
+ remarkPlugins: [
115
+ remarkFrontmatter,
116
+ remarkMdxFrontmatter,
117
+ remarkGfm,
118
+ remarkCodeTitles,
119
+ remarkToc,
120
+ ],
121
+ rehypePlugins: [
122
+ rehypeWrapJsxForHighlighting,
123
+ rehypeHighlight,
124
+ rehypeRemoveHighlightingFragments,
125
+ rehypeSlug,
126
+ [
127
+ // Must follow rehypeSlug: the anchor href points at the id it sets.
128
+ rehypeAutolinkHeadings,
129
+ {
130
+ behavior: 'append',
131
+ // A per-heading label keeps the permalink keyboard-reachable and
132
+ // distinguishable, rather than aria-hidden and mouse-only.
133
+ properties: (node: MdNode) => ({
134
+ className: 'heading-anchor',
135
+ 'aria-label': `Permalink to “${toText(node)}”`,
136
+ }),
137
+ content: { type: 'text', value: '#' },
138
+ },
139
+ ],
140
+ ],
141
+ }),
142
+ } as Plugin;
143
+ }
package/package.json CHANGED
@@ -1,103 +1,93 @@
1
1
  {
2
2
  "name": "@umami/shiso",
3
- "version": "0.54.0",
4
- "description": "A content layer for Next.js",
5
- "homepage": "https://shiso.umami.is",
3
+ "version": "0.61.0",
4
+ "description": "Open-source documentation framework for Markdown and MDX sites.",
5
+ "type": "module",
6
+ "bin": {
7
+ "shiso": "./bin/shiso.mjs"
8
+ },
9
+ "exports": {
10
+ "./client": {
11
+ "types": "./types/client.d.ts",
12
+ "default": "./src/entry-client.tsx"
13
+ },
14
+ "./search": {
15
+ "types": "./types/search.d.ts",
16
+ "default": "./src/lib/search/provider.ts"
17
+ },
18
+ "./schema": "./docs.schema.json",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "scripts",
24
+ "src",
25
+ "types",
26
+ "docs.schema.json",
27
+ "mdx.config.ts",
28
+ "vite.config.ts",
29
+ "CHANGELOG.md",
30
+ "README.md"
31
+ ],
32
+ "scripts": {
33
+ "prepack": "pnpm test:package",
34
+ "test:package": "node scripts/check-package.mjs"
35
+ },
36
+ "engines": {
37
+ "node": ">=20"
38
+ },
6
39
  "repository": {
7
40
  "type": "git",
8
- "url": "https://github.com/umami-software/shiso.git"
41
+ "url": "https://github.com/umami-software/shiso.git",
42
+ "directory": "packages/shiso"
9
43
  },
10
- "main": "dist/index.js",
11
- "module": "dist/index.mjs",
12
- "types": "dist/index.d.ts",
13
- "files": [
14
- "dist",
15
- "server",
16
- "styles.css"
44
+ "homepage": "https://shiso.umami.is",
45
+ "keywords": [
46
+ "shiso",
47
+ "documentation",
48
+ "docs",
49
+ "mdx",
50
+ "vite"
17
51
  ],
52
+ "license": "MIT",
18
53
  "publishConfig": {
19
54
  "access": "public"
20
55
  },
21
- "scripts": {
22
- "dev": "next dev --port 8001 --turbo",
23
- "build": "next build",
24
- "start": "next start",
25
- "lint": "next lint",
26
- "icons": "svgr ./src/assets --out-dir src/components/svg --typescript",
27
- "components": "tsc -p tsconfig.components.json",
28
- "server": "tsc -p tsconfig.server.json",
29
- "esbuild": "node esbuild.mjs",
30
- "dts": "node dts.mjs",
31
- "css": "node css.mjs",
32
- "bundle": "npm-run-all components server dts esbuild css"
33
- },
34
- "lint-staged": {
35
- "**/*.{js,jsx,ts,tsx}": [
36
- "prettier --write",
37
- "eslint"
38
- ],
39
- "**/*.css": [
40
- "stylelint --fix",
41
- "prettier --write"
42
- ],
43
- "**/*.json": [
44
- "prettier --write"
45
- ]
46
- },
47
56
  "dependencies": {
57
+ "@base-ui/react": "^1.7.0",
48
58
  "@fontsource/inter": "^5.2.5",
49
59
  "@fontsource/jetbrains-mono": "^5.2.5",
50
- "@mdx-js/loader": "^3.0.0",
51
- "@mdx-js/mdx": "^3.1.0",
52
- "@mdx-js/react": "^3.0.0",
53
- "@umami/react-zen": "^0.89.0",
60
+ "@mdx-js/react": "^3.1.1",
61
+ "@mdx-js/rollup": "^3.1.1",
62
+ "@tailwindcss/vite": "^4.3.3",
63
+ "@vitejs/plugin-react": "^6.0.5",
64
+ "ajv": "^8.20.0",
65
+ "class-variance-authority": "^0.7.1",
54
66
  "classnames": "^2.5.1",
55
- "date-fns": "^4.1.0",
56
- "glob": "^10.3.10",
57
- "gray-matter": "^4.0.3",
67
+ "clsx": "^2.1.1",
68
+ "cmdk": "^1.1.1",
69
+ "estree-util-value-to-estree": "^3.5.0",
70
+ "github-slugger": "^2.0.0",
58
71
  "highlight.js": "^11.11.1",
59
- "next": "15.2.1",
60
- "next-mdx-remote": "^5.0.0",
61
- "react": "^19.1.0",
62
- "react-dom": "^19.1.0",
63
- "recursive-readdir": "^2.2.3",
72
+ "lucide-react": "^1.28.0",
73
+ "react-router": "^8.3.0",
74
+ "rehype-autolink-headings": "^7.1.0",
64
75
  "rehype-highlight": "^7.0.1",
65
76
  "rehype-slug": "^6.0.0",
66
- "remark-gfm": "^4.0.0",
67
- "thenby": "^1.3.4"
68
- },
69
- "devDependencies": {
70
- "@svgr/cli": "^8.1.0",
71
- "@types/css-modules": "^1.0.5",
72
- "@types/mdx": "^2.0.11",
73
- "@types/node": "^22.15.3",
74
- "@types/react": "^19.1.2",
75
- "@types/react-dom": "^19.1.3",
76
- "@umami/esbuild-plugin-css-modules": "^0.4.0",
77
- "dts-bundle": "^0.7.3",
78
- "esbuild": "^0.25.3",
79
- "esbuild-plugin-css-modules": "^0.3.0",
80
- "eslint": "^9.25.1",
81
- "eslint-config-next": "15.1.3",
82
- "eslint-config-prettier": "^9.1.0",
83
- "eslint-import-resolver-alias": "^1.1.2",
84
- "eslint-plugin-import": "^2.29.1",
85
- "eslint-plugin-prettier": "^5.2.6",
86
- "husky": "^9.1.1",
87
- "lint-staged": "^15.5.1",
88
- "npm-run-all": "^4.1.5",
89
- "prettier": "^3.2.5",
90
77
  "remark-frontmatter": "^5.0.0",
91
- "remark-mdx-frontmatter": "^5.1.0",
92
- "stylelint": "^15.10.1",
93
- "stylelint-config-prettier": "^9.0.5",
94
- "stylelint-config-recommended": "^14.0.1",
95
- "typescript": "^5.8.3"
78
+ "remark-gfm": "^4.0.0",
79
+ "remark-mdx": "^3.1.1",
80
+ "remark-mdx-frontmatter": "^5.2.0",
81
+ "remark-parse": "^11.0.0",
82
+ "simple-icons": "^16.28.0",
83
+ "tailwind-merge": "^3.6.0",
84
+ "tailwindcss": "^4.3.3",
85
+ "tw-animate-css": "^1.4.0",
86
+ "unified": "^11.0.5",
87
+ "vite": "^8.2.0"
96
88
  },
97
- "pnpm": {
98
- "onlyBuiltDependencies": [
99
- "esbuild",
100
- "sharp"
101
- ]
89
+ "peerDependencies": {
90
+ "react": "^19.0.0",
91
+ "react-dom": "^19.0.0"
102
92
  }
103
93
  }
@@ -0,0 +1,18 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ const root = path.resolve(import.meta.dirname, '..');
5
+
6
+ for (const relativeFile of [
7
+ 'bin/shiso.mjs',
8
+ 'docs.schema.json',
9
+ 'src/entry-client.tsx',
10
+ 'src/entry-server.tsx',
11
+ 'types/client.d.ts',
12
+ 'types/search.d.ts',
13
+ 'vite.config.ts',
14
+ ]) {
15
+ await fs.access(path.join(root, relativeFile));
16
+ }
17
+
18
+ console.log('shiso package contents are ready.');
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Generates the project-local .shiso/icon-registry.generated.ts cache.
3
+ *
4
+ * Authors reference icons by name in MDX (`<Card icon="rocket">`). Importing all of
5
+ * lucide-react to support that would pull in ~4000 modules, so instead we scan the
6
+ * source for the icon names actually used and emit explicit named imports. The result
7
+ * tree-shakes normally and renders synchronously during prerender.
8
+ *
9
+ * Run via `pnpm icons:registry`, or automatically by the Vite plugin in vite.config.ts.
10
+ */
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+ import * as lucide from 'lucide-react';
15
+ import { loadDocsConfig } from './load-docs-config.mjs';
16
+
17
+ const DEFAULT_ROOT = process.cwd();
18
+ const SCAN_DIRS = ['content', 'src'];
19
+ const SCAN_EXTENSIONS = new Set(['.md', '.mdx', '.tsx']);
20
+
21
+ // Icons used by the docs component library itself, so pages always have a baseline set.
22
+ const ALWAYS_INCLUDE = ['rocket', 'code', 'book-open', 'circle-check', 'circle-alert'];
23
+
24
+ const ICON_ATTRIBUTE = /\bicon\s*=\s*["']([^"'{}\n]+)["']/g;
25
+
26
+ // docs.json declares icons as JSON properties rather than JSX/MDX attributes.
27
+ const ICON_JSON_PROPERTY = /"icon"\s*:\s*"([^"]+)"/g;
28
+ const NON_LUCIDE_ICONS = new Set([
29
+ 'bluesky',
30
+ 'discord',
31
+ 'facebook',
32
+ 'github',
33
+ 'hacker-news',
34
+ 'instagram',
35
+ 'linkedin',
36
+ 'medium',
37
+ 'reddit',
38
+ 'slack',
39
+ 'telegram',
40
+ 'threads',
41
+ 'twitter',
42
+ 'x',
43
+ 'x-twitter',
44
+ 'youtube',
45
+ ]);
46
+
47
+ /** `circle-check`, `circle_check`, `CircleCheck` -> `circle-check` */
48
+ export function normalizeIconName(name) {
49
+ return name
50
+ .trim()
51
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
52
+ .replace(/[\s_]+/g, '-')
53
+ .toLowerCase()
54
+ .replace(/-+/g, '-')
55
+ .replace(/^-|-$/g, '');
56
+ }
57
+
58
+ /** `circle-check` -> `CircleCheck` */
59
+ function toPascalCase(name) {
60
+ return name
61
+ .split('-')
62
+ .filter(Boolean)
63
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
64
+ .join('');
65
+ }
66
+
67
+ async function collectFiles(dir, files = []) {
68
+ let entries;
69
+
70
+ try {
71
+ entries = await fs.readdir(dir, { withFileTypes: true });
72
+ } catch {
73
+ return files;
74
+ }
75
+
76
+ for (const entry of entries) {
77
+ const fullPath = path.join(dir, entry.name);
78
+
79
+ if (entry.isDirectory()) {
80
+ if (entry.name === 'node_modules' || entry.name.startsWith('.')) {
81
+ continue;
82
+ }
83
+ await collectFiles(fullPath, files);
84
+ } else if (SCAN_EXTENSIONS.has(path.extname(entry.name))) {
85
+ files.push(fullPath);
86
+ }
87
+ }
88
+
89
+ return files;
90
+ }
91
+
92
+ function addMatches(names, source, pattern) {
93
+ for (const match of source.matchAll(pattern)) {
94
+ const value = match[1].trim();
95
+
96
+ if (
97
+ /^(?:https?:|data:image\/|\/|\.\.?\/)/i.test(value) ||
98
+ /\.(?:avif|gif|jpe?g|png|svg|webp)(?:[?#].*)?$/i.test(value)
99
+ ) {
100
+ continue;
101
+ }
102
+
103
+ const normalized = normalizeIconName(value);
104
+
105
+ if (normalized && /^[a-z0-9-]+$/.test(normalized)) {
106
+ names.add(normalized);
107
+ }
108
+ }
109
+ }
110
+
111
+ async function findIconNames(config, root) {
112
+ const names = new Set(ALWAYS_INCLUDE);
113
+
114
+ for (const dir of SCAN_DIRS) {
115
+ const files = await collectFiles(path.join(root, dir));
116
+
117
+ for (const file of files) {
118
+ addMatches(names, await fs.readFile(file, 'utf8'), ICON_ATTRIBUTE);
119
+ }
120
+ }
121
+
122
+ // Navigation and navbar icons live in docs.json, not in content or source.
123
+ const docsConfig = config ?? (await loadDocsConfig({ root })).config;
124
+ addMatches(names, JSON.stringify(docsConfig), ICON_JSON_PROPERTY);
125
+
126
+ return [...names].sort();
127
+ }
128
+
129
+ function renderModule(entries) {
130
+ const imports = entries.map(({ component }) => ` ${component},`).join('\n');
131
+ const mapping = entries
132
+ .map(({ name, component }) => {
133
+ // Match the formatter's "quote props as needed" style.
134
+ const key = /^[A-Za-z_$][\w$]*$/.test(name) ? name : `'${name}'`;
135
+
136
+ return ` ${key}: ${component},`;
137
+ })
138
+ .join('\n');
139
+
140
+ return `// Generated by scripts/generate-icon-registry.mjs. Do not edit by hand.
141
+ // Add icons by using them in content (\`icon="name"\`) and rebuilding.
142
+ import {
143
+ ${imports}
144
+ } from 'lucide-react';
145
+ import type { IconComponent } from '@/lib/icons';
146
+
147
+ export const ICON_REGISTRY: Record<string, IconComponent> = {
148
+ ${mapping}
149
+ };
150
+ `;
151
+ }
152
+
153
+ /** @param {{ config?: object, root?: string, output?: string }} [options] */
154
+ export async function generateIconRegistry({
155
+ config,
156
+ root = DEFAULT_ROOT,
157
+ output = path.join(root, '.shiso/icon-registry.generated.ts'),
158
+ } = {}) {
159
+ const names = await findIconNames(config, root);
160
+ const entries = [];
161
+ const unknown = [];
162
+
163
+ for (const name of names) {
164
+ if (NON_LUCIDE_ICONS.has(name)) {
165
+ continue;
166
+ }
167
+
168
+ const component = toPascalCase(name);
169
+
170
+ if (component in lucide) {
171
+ entries.push({ name, component });
172
+ } else {
173
+ unknown.push(name);
174
+ }
175
+ }
176
+
177
+ const contents = renderModule(entries);
178
+ const previous = await fs.readFile(output, 'utf8').catch(() => '');
179
+
180
+ if (previous !== contents) {
181
+ await fs.mkdir(path.dirname(output), { recursive: true });
182
+ await fs.writeFile(output, contents);
183
+ }
184
+
185
+ return { count: entries.length, unknown, changed: previous !== contents };
186
+ }
187
+
188
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
189
+ const { count, unknown } = await generateIconRegistry();
190
+
191
+ console.log(`Icon registry: ${count} icons`);
192
+
193
+ if (unknown.length) {
194
+ console.warn(`Unknown icon names (not in lucide): ${unknown.join(', ')}`);
195
+ }
196
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Generates the project-local .shiso/last-modified.ts cache.
3
+ *
4
+ * The `metadata.timestamp` config key shows a last-modified date on pages.
5
+ * That date comes from git history, which only exists at build time, so a
6
+ * single `git log` pass over the repo maps every content file to the date of
7
+ * the latest commit that touched it. Files not committed yet fall back to
8
+ * filesystem mtime.
9
+ *
10
+ * Run via `pnpm timestamps`, or automatically by the Vite plugin exported below.
11
+ */
12
+ import { execFile } from 'node:child_process';
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { pathToFileURL } from 'node:url';
16
+ import { promisify } from 'node:util';
17
+
18
+ const execFileAsync = promisify(execFile);
19
+
20
+ const DEFAULT_ROOT = process.cwd();
21
+ const CONTENT_EXTENSIONS = new Set(['.md', '.mdx']);
22
+
23
+ async function collectContentFiles(dir, files = []) {
24
+ let entries;
25
+
26
+ try {
27
+ entries = await fs.readdir(dir, { withFileTypes: true });
28
+ } catch {
29
+ return files;
30
+ }
31
+
32
+ for (const entry of entries) {
33
+ const fullPath = path.join(dir, entry.name);
34
+
35
+ if (entry.isDirectory()) {
36
+ await collectContentFiles(fullPath, files);
37
+ } else if (CONTENT_EXTENSIONS.has(path.extname(entry.name))) {
38
+ files.push(fullPath);
39
+ }
40
+ }
41
+
42
+ return files;
43
+ }
44
+
45
+ /** Latest commit date per repo path, from one newest-first `git log` pass. */
46
+ async function getGitDates(root) {
47
+ const dates = new Map();
48
+
49
+ try {
50
+ const { stdout } = await execFileAsync(
51
+ 'git',
52
+ ['-c', 'core.quotePath=false', 'log', '--format=@%cI', '--name-only', '--', 'content'],
53
+ { cwd: root, maxBuffer: 64 * 1024 * 1024 },
54
+ );
55
+
56
+ let current = '';
57
+
58
+ for (const line of stdout.split('\n')) {
59
+ if (line.startsWith('@')) {
60
+ current = line.slice(1).trim();
61
+ } else if (line.trim() && current && !dates.has(line.trim())) {
62
+ dates.set(line.trim(), current);
63
+ }
64
+ }
65
+ } catch {
66
+ // Not a git checkout (e.g. a tarball build); every file uses mtime.
67
+ }
68
+
69
+ return dates;
70
+ }
71
+
72
+ export async function generateLastModified({
73
+ root = DEFAULT_ROOT,
74
+ output = path.join(root, '.shiso/last-modified.ts'),
75
+ } = {}) {
76
+ const files = await collectContentFiles(path.join(root, 'content'));
77
+ const gitDates = await getGitDates(root);
78
+ const entries = [];
79
+
80
+ for (const file of files.sort()) {
81
+ const repoPath = path.relative(root, file).split(path.sep).join('/');
82
+ let date = gitDates.get(repoPath);
83
+
84
+ if (!date) {
85
+ const stat = await fs.stat(file);
86
+ date = stat.mtime.toISOString();
87
+ }
88
+
89
+ // Module keys as produced by import.meta.glob: "/content/docs/index.mdx".
90
+ entries.push([`/${repoPath}`, date]);
91
+ }
92
+
93
+ const mapping = entries.map(([key, date]) => ` '${key}': '${date}',`).join('\n');
94
+ const contents = `// Generated by scripts/generate-last-modified.mjs. Do not edit by hand.
95
+ export const LAST_MODIFIED: Record<string, string> = {
96
+ ${mapping}
97
+ };
98
+ `;
99
+
100
+ const previous = await fs.readFile(output, 'utf8').catch(() => '');
101
+
102
+ if (previous !== contents) {
103
+ await fs.mkdir(path.dirname(output), { recursive: true });
104
+ await fs.writeFile(output, contents);
105
+ }
106
+
107
+ return { count: entries.length, changed: previous !== contents };
108
+ }
109
+
110
+ /** Ensures consumers can import the generated module before Vite loads app code. */
111
+ export function shisoLastModified(options = {}) {
112
+ return {
113
+ name: 'shiso-last-modified',
114
+ async buildStart() {
115
+ await generateLastModified(options);
116
+ },
117
+ async handleHotUpdate({ file }) {
118
+ if (/\.(md|mdx)$/.test(file)) {
119
+ await generateLastModified(options);
120
+ }
121
+ },
122
+ };
123
+ }
124
+
125
+ if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
126
+ const { count } = await generateLastModified();
127
+ console.log(`Last-modified dates: ${count} files`);
128
+ }