@lupinum/ginko-docs 0.4.0 → 0.5.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 (49) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +111 -16
  3. package/app/app.config.ts +2 -0
  4. package/app/assets/docs.css +79 -15
  5. package/app/components/DocsBanner.vue +1 -1
  6. package/app/components/DocsExample.vue +1 -1
  7. package/app/components/DocsHeader.vue +7 -6
  8. package/app/components/DocsIcon.vue +12 -0
  9. package/app/components/DocsLanding.vue +8 -5
  10. package/app/components/DocsLanguage.vue +18 -7
  11. package/app/components/DocsNavSheet.vue +37 -0
  12. package/app/components/DocsNavTree.vue +2 -2
  13. package/app/components/DocsPage.vue +16 -12
  14. package/app/components/DocsPageActions.vue +5 -5
  15. package/app/components/DocsPageEnd.ts +12 -0
  16. package/app/components/DocsPost.vue +11 -11
  17. package/app/components/DocsSearch.vue +7 -5
  18. package/app/components/DocsShell.vue +19 -3
  19. package/app/components/DocsSidebar.vue +2 -1
  20. package/app/components/DocsSidebarEnd.ts +4 -0
  21. package/app/components/DocsThemeToggle.vue +3 -3
  22. package/app/components/DocsToc.vue +2 -2
  23. package/app/composables/useDocs.ts +3 -2
  24. package/app/composables/useDocsBlog.ts +8 -7
  25. package/app/composables/useDocsFrame.ts +11 -0
  26. package/app/composables/useDocsSite.ts +24 -3
  27. package/app/docs-pages/blog.vue +12 -4
  28. package/app/docs-pages/changelog.vue +13 -5
  29. package/app/docs-pages/doc.vue +3 -1
  30. package/app/error.vue +16 -1
  31. package/app/layouts/default.vue +4 -0
  32. package/app/lib/docsPage.ts +40 -0
  33. package/app/lib/markdownComponents.ts +1 -2
  34. package/dist/config.d.mts +28 -8
  35. package/dist/config.d.ts +28 -8
  36. package/dist/config.mjs +16 -47
  37. package/dist/icons.d.mts +38 -0
  38. package/dist/icons.d.ts +38 -0
  39. package/dist/icons.mjs +175 -0
  40. package/dist/pages.mjs +56 -24
  41. package/i18n/de.json +1 -1
  42. package/i18n/en.json +1 -1
  43. package/i18n/fr.json +1 -0
  44. package/nuxt.config.ts +2 -17
  45. package/package.json +11 -10
  46. package/server/lib/blogFeed.ts +1 -1
  47. package/shared/layerRoutes.ts +66 -0
  48. package/dist/shared/ginko-docs.BwbF7JBR.mjs +0 -4
  49. package/server/lib/rss.ts +0 -57
package/dist/icons.mjs ADDED
@@ -0,0 +1,175 @@
1
+ import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { join, relative, resolve, extname } from 'node:path';
4
+ import { defineNuxtModule, createResolver, useLogger, addComponent, addTemplate, addTypeTemplate, updateTemplates } from '@nuxt/kit';
5
+ import { getIconData, iconToSVG } from '@iconify/utils';
6
+
7
+ const SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".nuxt", ".output", "dist"]);
8
+ const walk = (dir, extensions, out = []) => {
9
+ if (!existsSync(dir)) return out;
10
+ for (const name of readdirSync(dir)) {
11
+ if (SKIP_DIRS.has(name) || name.startsWith(".")) continue;
12
+ const path = join(dir, name);
13
+ if (statSync(path).isDirectory()) walk(path, extensions, out);
14
+ else if (extensions.includes(extname(name))) out.push(path);
15
+ }
16
+ return out;
17
+ };
18
+ const OWN_NAME = /['"`]((?:lucide|docs):[a-z0-9]+(?:-[a-z0-9]+)*)['"`]/g;
19
+ const NAME = "[a-z0-9]+(?:-[a-z0-9]+)*(?::[a-z0-9]+(?:-[a-z0-9]+)*)?";
20
+ const QUOTED_NAME = new RegExp(`\\bicon\\??\\s*:\\s*['"](${NAME})['"]`, "g");
21
+ const YAML_NAME = new RegExp(`\\bicon["']?\\s*:\\s*["']?(${NAME})["']?(?=\\s|$|,|\\}|\\))`, "gm");
22
+ const ATTR_NAME = new RegExp(`(?<![\\w-])(?::icon=["']['"]|(?<!:)icon=["'])(${NAME})["']`, "g");
23
+ const ICON_TAG = new RegExp(`<Icon\\b[^>]*?\\s(?::name=["']['"]|name=["'])(${NAME})["']`, "g");
24
+ const matches = (re, source) => [...source.matchAll(re)].map((m) => m[1].includes(":") ? m[1] : `lucide:${m[1]}`);
25
+ const without = (source, open, close) => {
26
+ let out = "";
27
+ let at = 0;
28
+ for (; ; ) {
29
+ const start = source.indexOf(open, at);
30
+ const end = start === -1 ? -1 : source.indexOf(close, start + open.length);
31
+ if (end === -1) return out + source.slice(at);
32
+ out += source.slice(at, start);
33
+ at = end + close.length;
34
+ }
35
+ };
36
+ const mapScripts = (source, fn) => {
37
+ let out = "";
38
+ let at = 0;
39
+ for (; ; ) {
40
+ const tag = source.indexOf("<script", at);
41
+ const open = tag === -1 ? -1 : source.indexOf(">", tag);
42
+ const close = open === -1 ? -1 : source.indexOf("<\/script>", open);
43
+ if (close === -1) return out + source.slice(at);
44
+ out += source.slice(at, open + 1) + fn(source.slice(open + 1, close));
45
+ at = close;
46
+ }
47
+ };
48
+ const cutLine = (line) => {
49
+ for (let i = line.indexOf("//"); i !== -1; i = line.indexOf("//", i + 2)) if (i === 0 || /\s/.test(line[i - 1])) return line.slice(0, i);
50
+ return line;
51
+ };
52
+ const stripCode = (code) => without(code, "/*", "*/").split("\n").map(cutLine).join("\n");
53
+ const stripComments = (source, file = "") => file.endsWith(".vue") ? mapScripts(without(source, "<!--", "-->"), stripCode) : stripCode(source);
54
+ const codeIconNames = (source, file) => {
55
+ const code = stripComments(source, file);
56
+ return file.endsWith(".vue") ? [...matches(ICON_TAG, code), ...matches(ATTR_NAME, code)] : matches(QUOTED_NAME, code);
57
+ };
58
+ const contentIconNames = (source, file) => {
59
+ if (!file.endsWith(".md")) return matches(YAML_NAME, source);
60
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(source);
61
+ const body = (front ? source.slice(front[0].length) : source).replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, "").replace(/`[^`\n]*`/g, "");
62
+ const names = front ? matches(YAML_NAME, front[1]) : [];
63
+ for (const m of body.matchAll(/:{1,}[a-z][\w-]*(?:\[[^\]\n]*\])?\{([^}\n]*)\}/g)) names.push(...matches(ATTR_NAME, m[1]));
64
+ for (const m of body.matchAll(/^:{2,}[\w-]+[^\n]*\n---\r?\n([\s\S]*?)\r?\n---/gm)) names.push(...matches(YAML_NAME, m[1]));
65
+ return names;
66
+ };
67
+ const readIcon = (name, roots, customDir, sets = /* @__PURE__ */ new Map()) => {
68
+ const [prefix = "", icon = ""] = name.split(":");
69
+ if (prefix === "docs") {
70
+ const file = join(customDir, `${icon}.svg`);
71
+ if (!existsSync(file)) return;
72
+ const svg2 = readFileSync(file, "utf8");
73
+ const body = /<svg[^>]*>([\s\S]*)<\/svg>/.exec(svg2)?.[1]?.trim();
74
+ const viewBox = /viewBox="([^"]+)"/.exec(svg2)?.[1];
75
+ return body && viewBox ? [body, viewBox] : void 0;
76
+ }
77
+ if (!sets.has(prefix)) {
78
+ for (const from of roots) {
79
+ try {
80
+ sets.set(prefix, JSON.parse(readFileSync(createRequire(join(from, "noop.js")).resolve(`@iconify-json/${prefix}/icons.json`), "utf8")));
81
+ break;
82
+ } catch {
83
+ }
84
+ }
85
+ if (!sets.has(prefix)) sets.set(prefix, void 0);
86
+ }
87
+ const set = sets.get(prefix);
88
+ const data = set && getIconData(set, icon);
89
+ if (!data) return;
90
+ const svg = iconToSVG(data);
91
+ return [svg.body, svg.attributes.viewBox];
92
+ };
93
+ const collectIcons = (o) => {
94
+ const found = /* @__PURE__ */ new Map();
95
+ const add = (name, file) => found.set(name, (found.get(name) ?? /* @__PURE__ */ new Set()).add(file));
96
+ const read = (file) => readFileSync(file, "utf8");
97
+ for (const file of walk(join(o.layerRoot, "app"), [".vue", ".ts"])) {
98
+ for (const m of stripComments(read(file), file).matchAll(OWN_NAME)) add(m[1], relative(o.layerRoot, file));
99
+ }
100
+ for (const root of o.roots) {
101
+ const files = [
102
+ ...walk(join(root, "content"), [".md", ".yml", ".yaml", ".json"]),
103
+ ...["app/app.config.ts", "app.config.ts"].map((f) => join(root, f)).filter(existsSync)
104
+ ];
105
+ for (const file of files) {
106
+ const names = file.endsWith(".ts") ? codeIconNames(read(file), file) : contentIconNames(read(file), file);
107
+ for (const name of names) add(name, relative(o.relativeTo, file));
108
+ }
109
+ if (resolve(root) !== resolve(o.layerRoot)) {
110
+ for (const file of walk(join(root, "app"), [".vue"])) {
111
+ for (const name of codeIconNames(read(file), file)) add(name, relative(o.relativeTo, file));
112
+ }
113
+ }
114
+ }
115
+ const sets = /* @__PURE__ */ new Map();
116
+ const customDir = join(o.layerRoot, "icons");
117
+ const icons = {};
118
+ const warnings = [];
119
+ for (const name of [...found.keys()].sort()) {
120
+ const entry = readIcon(name, o.resolveFrom, customDir, sets);
121
+ if (entry) icons[name] = entry;
122
+ else warnings.push(`Icon "${name}" (${[...found.get(name)][0]}) is not in an installed Iconify set; it renders nothing.`);
123
+ }
124
+ return { icons, warnings };
125
+ };
126
+ const docsIcons = defineNuxtModule({
127
+ meta: { name: "@lupinum/ginko-docs/icons" },
128
+ setup(_, nuxt) {
129
+ const { resolve: here } = createResolver(import.meta.url);
130
+ const logger = useLogger("@lupinum/ginko-docs");
131
+ const layerRoot = here("..");
132
+ addComponent({ name: "Icon", filePath: here("../app/components/DocsIcon.vue"), global: true, priority: -1 });
133
+ const warned = /* @__PURE__ */ new Set();
134
+ const collect = () => {
135
+ const { icons, warnings } = collectIcons({
136
+ layerRoot,
137
+ roots: nuxt.options._layers.map((layer) => layer.config.rootDir ?? layer.cwd),
138
+ resolveFrom: [nuxt.options.rootDir, layerRoot],
139
+ relativeTo: nuxt.options.rootDir
140
+ });
141
+ for (const warning of warnings) if (!warned.has(warning)) {
142
+ warned.add(warning);
143
+ logger.warn(warning);
144
+ }
145
+ return icons;
146
+ };
147
+ const template = addTemplate({
148
+ filename: "docs-icons.mjs",
149
+ write: true,
150
+ getContents: () => `export default ${JSON.stringify(collect())}
151
+ `
152
+ });
153
+ nuxt.options.alias["#docs-icons"] = template.dst;
154
+ addTypeTemplate({
155
+ filename: "types/docs-icons.d.ts",
156
+ getContents: () => `declare module '#docs-icons' {
157
+ const icons: Record<string, [body: string, viewBox: string]>
158
+ export default icons
159
+ }
160
+ `
161
+ });
162
+ nuxt.hook("build:manifest", (manifest) => {
163
+ for (const [key, item] of Object.entries(manifest)) {
164
+ if ([key, item.file, item.name, item.src].some((s) => s && /DocsIcon|docs-icons/.test(s))) item.prefetch = false;
165
+ }
166
+ });
167
+ if (nuxt.options.dev) {
168
+ nuxt.hook("builder:watch", async (_event, path) => {
169
+ if (/\.(md|ya?ml|json|vue)$|app\.config\.ts$/.test(path)) await updateTemplates({ filter: (t) => t.filename === "docs-icons.mjs" });
170
+ });
171
+ }
172
+ }
173
+ });
174
+
175
+ export { codeIconNames, collectIcons, contentIconNames, docsIcons as default, readIcon, stripComments };
package/dist/pages.mjs CHANGED
@@ -1,35 +1,67 @@
1
- import { defineNuxtModule, createResolver, extendPages, addServerHandler, useLogger } from '@nuxt/kit';
2
- import { B as BLOG_PATH, C as CHANGELOG_PATH } from './shared/ginko-docs.BwbF7JBR.mjs';
1
+ import { defineNuxtModule, createResolver, useLogger, addServerHandler } from '@nuxt/kit';
2
+
3
+ function listPath(route) {
4
+ if (!route) return void 0;
5
+ const list = route.replace(/\/\[slug\]$/, "");
6
+ return list.includes("[") ? void 0 : list || "/";
7
+ }
8
+ function addressesOf(page, main) {
9
+ const mapped = page.meta?.i18n && page.meta.i18n.paths?.[main];
10
+ return mapped ? [page.path, mapped] : [page.path];
11
+ }
12
+ function layerRoutes(site, pages) {
13
+ const bricks = site?.bricks ?? {};
14
+ const main = site?.locales?.[0] ?? "";
15
+ const taken = /* @__PURE__ */ new Set();
16
+ for (const page of pages) for (const path of addressesOf(page, main)) taken.add(path);
17
+ const docsBase = bricks.docs?.route?.[main]?.replace(/\/\[slug\]$/, "") ?? "/docs";
18
+ const blog = bricks.posts ? listPath(bricks.posts.route?.[main]) : void 0;
19
+ const changelog = bricks.changelog ? listPath(bricks.changelog.route?.[main]) ?? site?.pages?.changelog?.[main] : void 0;
20
+ const free = (path) => path && !taken.has(path) ? path : void 0;
21
+ const noAddress = [...bricks.posts && !blog ? ["posts"] : [], ...bricks.changelog && !changelog ? ["changelog"] : []];
22
+ const addedBlog = free(blog);
23
+ const addedChangelog = free(changelog);
24
+ const wanted = [
25
+ { name: "docs-slug", path: `${docsBase}/:slug()`, file: "doc.vue" },
26
+ { name: "docs", path: docsBase, file: "docs.vue" },
27
+ { name: "index", path: "/", file: "index.vue" },
28
+ addedBlog ? { name: "blog", path: addedBlog, file: "blog.vue" } : void 0,
29
+ addedBlog ? { name: "blog-slug", path: `${addedBlog}/:slug()`, file: "post.vue" } : void 0,
30
+ addedChangelog ? { name: "changelog", path: addedChangelog, file: "changelog.vue" } : void 0
31
+ ];
32
+ return {
33
+ docs: { base: docsBase },
34
+ addresses: { ...blog && { blog }, ...changelog && { changelog } },
35
+ pages: wanted.filter((page) => !!page && !taken.has(page.path)),
36
+ ...addedBlog && { blog: addedBlog },
37
+ ...addedChangelog && { changelog: addedChangelog },
38
+ noAddress
39
+ };
40
+ }
3
41
 
4
42
  const docsPages = defineNuxtModule({
5
43
  meta: { name: "@lupinum/ginko-docs/pages" },
6
44
  setup(_, nuxt) {
7
45
  const { resolve } = createResolver(import.meta.url);
46
+ const logger = useLogger("ginko-docs");
8
47
  const site = nuxt.options.ginko;
9
- const bricks = site?.bricks ?? {};
10
- const main = site?.locales?.[0] ?? "";
11
- const base = bricks.docs?.route?.[main]?.replace(/\/\[slug\]$/, "") ?? "/docs";
12
- extendPages((pages) => {
13
- const taken = new Set(pages.map((p) => p.path));
14
- const add = (name, path, file) => {
15
- if (!taken.has(path)) pages.push({ name, path, file: resolve(`../app/docs-pages/${file}`) });
16
- };
17
- add("docs-slug", `${base}/:slug()`, "doc.vue");
18
- add("docs", base, "docs.vue");
19
- add("index", "/", "index.vue");
20
- if (bricks.posts) {
21
- add("blog", BLOG_PATH, "blog.vue");
22
- add("blog-slug", `${BLOG_PATH}/:slug()`, "post.vue");
48
+ const { addresses } = layerRoutes(site, []);
49
+ nuxt.options.runtimeConfig.public.ginkoDocs = { blog: addresses.blog ?? "", changelog: addresses.changelog ?? "" };
50
+ let announced = false;
51
+ nuxt.hook("pages:resolved", (pages) => {
52
+ const r = layerRoutes(site, pages);
53
+ for (const page of r.pages) pages.push({ ...page, file: resolve(`../app/docs-pages/${page.file}`) });
54
+ if (announced) return;
55
+ announced = true;
56
+ if (r.blog) {
57
+ addServerHandler({ route: `${r.blog}/rss.xml`, method: "get", handler: resolve("../server/feeds/blog.get") });
58
+ addServerHandler({ route: `/:locale${r.blog}/rss.xml`, method: "get", handler: resolve("../server/feeds/blog-locale.get") });
59
+ if (!site?.pages?.blog) logger.warn(`The layer adds the blog at ${r.blog} but the site has no \`blog: blogPages({ locales })\` in \`pages\`: the blog list is missing from the sitemap and the static build.`);
23
60
  }
24
- if (bricks.changelog) add("changelog", CHANGELOG_PATH, "changelog.vue");
61
+ if (r.changelog && !site?.pages?.changelog) logger.warn(`The layer adds the changelog at ${r.changelog} but the site has no \`changelog: changelogPages({ locales })\` in \`pages\`: it is missing from the sitemap and the static build.`);
62
+ for (const brick of r.noAddress)
63
+ logger.warn(brick === "changelog" ? "The changelog brick has no address: give it a `route` or add `changelog: changelogPages({ locales })` to `pages`. No changelog page is added." : "The posts brick has no `route` for the main language, so the layer adds no blog page.");
25
64
  });
26
- if (bricks.posts) {
27
- addServerHandler({ route: `${BLOG_PATH}/rss.xml`, method: "get", handler: resolve("../server/feeds/blog.get") });
28
- addServerHandler({ route: `/:locale${BLOG_PATH}/rss.xml`, method: "get", handler: resolve("../server/feeds/blog-locale.get") });
29
- }
30
- const logger = useLogger("ginko-docs");
31
- if (bricks.posts && !site?.pages?.blog) logger.warn("The site has a blog but no `blog: blogPages({ locales })` in `pages`: the blog list is missing from the sitemap and the static build.");
32
- if (bricks.changelog && !site?.pages?.changelog) logger.warn("The site has a changelog but no `changelog: changelogPages({ locales })` in `pages`: it is missing from the sitemap and the static build.");
33
65
  }
34
66
  });
35
67
 
package/i18n/de.json CHANGED
@@ -1 +1 @@
1
- {"docs":{"docs":"Doku","edit":"Seite bearbeiten","issue":"Fehler melden","top":"Nach oben","updated":"Zuletzt geändert am","copyPage":"Seite kopieren","copied":"Kopiert","copyFailed":"Kopieren nicht möglich","table":"Tabelle, scrollbar","installCommand":"Installationsbefehl","searchResults":"Suchergebnisse","copyCommand":"Befehl kopieren","moreActions":"Weitere Seitenaktionen","viewMarkdown":"Als Markdown ansehen","viewMarkdownHint":"Die Seite als reiner Text","openChatGpt":"In ChatGPT öffnen","openClaude":"In Claude öffnen","askHint":"Fragen zu dieser Seite stellen","askPrompt":"Lies {url} und beantworte Fragen dazu.","pageActions":"Seitenaktionen","skip":"Zum Inhalt","nav":"Doku-Navigation","onThisPage":"Auf dieser Seite","search":"Suchen","searchPlaceholder":"Doku durchsuchen …","searchHint":"Tippe, um Seiten und Abschnitte zu finden.","noResults":"Nichts gefunden für „{query}“.","searchMove":"wählen","searchOpen":"öffnen","close":"Schließen","previous":"Zurück","next":"Weiter","pager":"Vorige und nächste Seite","language":"Sprache","sections":"Bereiche","breadcrumb":"Pfad","theme":{"label":"Darstellung","auto":"Auto","light":"Hell","dark":"Dunkel","toggle":"Zwischen hell und dunkel wechseln"},"bannerClose":"Hinweis schließen","footerNav":"Fußzeile","builtWith":"Erstellt mit Ginko","toggle":"Unterseiten von {title} ein- oder ausklappen","blog":"Blog","changelog":"Änderungen","by":"Von","rss":"RSS-Feed","siteLinks":"Hauptbereiche","siteMenu":"Hauptmenü","blogLead":"Neuigkeiten, Anleitungen und Einblicke.","changelogLead":"Was sich mit jeder Version geändert hat.","draft":"Entwurf","readMore":"Weiterlesen","example":{"preview":"Vorschau","source":"Markdown"},"notFound":{"title":"Seite nicht gefunden","text":"Diese Seite gibt es nicht oder sie ist umgezogen.","home":"Zur Startseite"},"error":{"title":"Etwas ist schiefgelaufen","text":"Die Seite konnte nicht geladen werden. Versuche es gleich noch einmal."}}}
1
+ {"docs":{"docs":"Doku","edit":"Seite bearbeiten","issue":"Fehler melden","top":"Nach oben","updated":"Zuletzt geändert am","copyPage":"Seite kopieren","copied":"Kopiert","copyFailed":"Kopieren nicht möglich","table":"Tabelle, scrollbar","installCommand":"Installationsbefehl","searchResults":"Suchergebnisse","copyCommand":"Befehl kopieren","moreActions":"Weitere Seitenaktionen","viewMarkdown":"Als Markdown ansehen","viewMarkdownHint":"Die Seite als reiner Text","openChatGpt":"In ChatGPT öffnen","openClaude":"In Claude öffnen","askHint":"Fragen zu dieser Seite stellen","askPrompt":"Lies {url} und beantworte Fragen dazu.","pageActions":"Seitenaktionen","skip":"Zum Inhalt","nav":"Doku-Navigation","contents":"Inhalt","onThisPage":"Auf dieser Seite","search":"Suchen","searchPlaceholder":"Doku durchsuchen …","searchHint":"Tippe, um Seiten und Abschnitte zu finden.","noResults":"Nichts gefunden für „{query}“.","searchMove":"wählen","searchOpen":"öffnen","close":"Schließen","previous":"Zurück","next":"Weiter","pager":"Vorige und nächste Seite","language":"Sprache","sections":"Bereiche","breadcrumb":"Pfad","theme":{"label":"Darstellung","auto":"Auto","light":"Hell","dark":"Dunkel","toggle":"Zwischen hell und dunkel wechseln"},"bannerClose":"Hinweis schließen","footerNav":"Fußzeile","builtWith":"Erstellt mit Ginko","toggle":"Unterseiten von {title} ein- oder ausklappen","blog":"Blog","changelog":"Änderungen","by":"Von","rss":"RSS-Feed","siteLinks":"Hauptbereiche","siteMenu":"Hauptmenü","blogLead":"Neuigkeiten, Anleitungen und Einblicke.","changelogLead":"Was sich mit jeder Version geändert hat.","changelogEmpty":"Noch keine Versionen. Lege die erste als Markdown-Datei in content/changelog an.","draft":"Entwurf","readMore":"Weiterlesen","example":{"preview":"Vorschau","source":"Markdown"},"notFound":{"title":"Seite nicht gefunden","text":"Diese Seite gibt es nicht oder sie ist umgezogen.","home":"Zur Startseite","mainLanguage":"Auf {language} versuchen"},"error":{"title":"Etwas ist schiefgelaufen","text":"Die Seite konnte nicht geladen werden. Versuche es gleich noch einmal."},"untranslated":"Nicht übersetzt: öffnet die Startseite"}}
package/i18n/en.json CHANGED
@@ -1 +1 @@
1
- {"docs":{"docs":"Docs","edit":"Edit this page","issue":"Report an issue","top":"Back to top","updated":"Last updated","copyPage":"Copy page","copied":"Copied","copyFailed":"Could not copy","table":"Table, scrollable","installCommand":"Install command","searchResults":"Search results","copyCommand":"Copy command","moreActions":"More page actions","viewMarkdown":"View as Markdown","viewMarkdownHint":"The page as plain text","openChatGpt":"Open in ChatGPT","openClaude":"Open in Claude","askHint":"Ask questions about this page","askPrompt":"Read {url} and answer questions about it.","pageActions":"Page actions","skip":"Skip to content","nav":"Docs navigation","onThisPage":"On this page","search":"Search","searchPlaceholder":"Search the docs…","searchHint":"Type to search pages and sections.","noResults":"No results for “{query}”.","searchMove":"to move","searchOpen":"to open","close":"Close","previous":"Previous","next":"Next","pager":"Previous and next page","language":"Language","sections":"Sections","breadcrumb":"Breadcrumb","theme":{"label":"Appearance","auto":"Auto","light":"Light","dark":"Dark","toggle":"Switch between light and dark"},"bannerClose":"Dismiss notice","footerNav":"Footer","builtWith":"Built with Ginko","toggle":"Show or hide subpages of {title}","blog":"Blog","changelog":"Changelog","by":"By","rss":"RSS feed","siteLinks":"Main","siteMenu":"Main menu","blogLead":"News, guides and insights.","changelogLead":"What changed with each version.","draft":"Draft","readMore":"Read more","example":{"preview":"Preview","source":"Markdown"},"notFound":{"title":"Page not found","text":"This page does not exist or has moved.","home":"Go to the home page"},"error":{"title":"Something went wrong","text":"The page could not be loaded. Please try again in a moment."}}}
1
+ {"docs":{"docs":"Docs","edit":"Edit this page","issue":"Report an issue","top":"Back to top","updated":"Last updated","copyPage":"Copy page","copied":"Copied","copyFailed":"Could not copy","table":"Table, scrollable","installCommand":"Install command","searchResults":"Search results","copyCommand":"Copy command","moreActions":"More page actions","viewMarkdown":"View as Markdown","viewMarkdownHint":"The page as plain text","openChatGpt":"Open in ChatGPT","openClaude":"Open in Claude","askHint":"Ask questions about this page","askPrompt":"Read {url} and answer questions about it.","pageActions":"Page actions","skip":"Skip to content","nav":"Docs navigation","contents":"Contents","onThisPage":"On this page","search":"Search","searchPlaceholder":"Search the docs…","searchHint":"Type to search pages and sections.","noResults":"No results for “{query}”.","searchMove":"to move","searchOpen":"to open","close":"Close","previous":"Previous","next":"Next","pager":"Previous and next page","language":"Language","sections":"Sections","breadcrumb":"Breadcrumb","theme":{"label":"Appearance","auto":"Auto","light":"Light","dark":"Dark","toggle":"Switch between light and dark"},"bannerClose":"Dismiss notice","footerNav":"Footer","builtWith":"Built with Ginko","toggle":"Show or hide subpages of {title}","blog":"Blog","changelog":"Changelog","by":"By","rss":"RSS feed","siteLinks":"Main","siteMenu":"Main menu","blogLead":"News, guides and insights.","changelogLead":"What changed with each version.","changelogEmpty":"No releases yet. Add the first as a Markdown file in content/changelog.","draft":"Draft","readMore":"Read more","example":{"preview":"Preview","source":"Markdown"},"notFound":{"title":"Page not found","text":"This page does not exist or has moved.","home":"Go to the home page","mainLanguage":"Try in {language}"},"error":{"title":"Something went wrong","text":"The page could not be loaded. Please try again in a moment."},"untranslated":"Not translated: opens the start page"}}
package/i18n/fr.json ADDED
@@ -0,0 +1 @@
1
+ {"docs":{"docs":"Documentation","edit":"Modifier cette page","issue":"Signaler un problème","top":"Retour en haut","updated":"Dernière mise à jour","copyPage":"Copier la page","copied":"Copié","copyFailed":"Impossible de copier","table":"Tableau, défilement horizontal","installCommand":"Commande d’installation","searchResults":"Résultats de recherche","copyCommand":"Copier la commande","moreActions":"Autres actions sur la page","viewMarkdown":"Voir en Markdown","viewMarkdownHint":"La page en texte brut","openChatGpt":"Ouvrir dans ChatGPT","openClaude":"Ouvrir dans Claude","askHint":"Poser des questions sur cette page","askPrompt":"Lis {url} et réponds à mes questions sur cette page.","pageActions":"Actions de la page","skip":"Aller au contenu","nav":"Navigation de la documentation","contents":"Sommaire","onThisPage":"Sur cette page","search":"Rechercher","searchPlaceholder":"Rechercher dans la documentation…","searchHint":"Saisissez un mot pour chercher dans les pages et les sections.","noResults":"Aucun résultat pour « {query} ».","searchMove":"pour naviguer","searchOpen":"pour ouvrir","close":"Fermer","previous":"Précédent","next":"Suivant","pager":"Page précédente et page suivante","language":"Langue","sections":"Rubriques","breadcrumb":"Fil d’Ariane","theme":{"label":"Apparence","auto":"Auto","light":"Clair","dark":"Sombre","toggle":"Basculer entre clair et sombre"},"bannerClose":"Fermer le message","footerNav":"Pied de page","builtWith":"Réalisé avec Ginko","toggle":"Afficher ou masquer les sous-pages de {title}","blog":"Blog","changelog":"Nouveautés","by":"Par","rss":"Flux RSS","siteLinks":"Navigation principale","siteMenu":"Menu principal","blogLead":"Actualités, guides et analyses.","changelogLead":"Ce qui a changé à chaque version.","changelogEmpty":"Aucune version pour l’instant. Ajoutez la première sous forme de fichier Markdown dans content/changelog.","draft":"Brouillon","readMore":"Lire la suite","example":{"preview":"Aperçu","source":"Markdown"},"notFound":{"title":"Page introuvable","text":"Cette page n’existe pas ou a été déplacée.","home":"Aller à la page d’accueil","mainLanguage":"Lire en {language}"},"error":{"title":"Une erreur est survenue","text":"La page n’a pas pu être chargée. Réessayez dans un instant."},"untranslated":"Page non traduite : ouvre la page d’accueil"}}
package/nuxt.config.ts CHANGED
@@ -6,29 +6,14 @@ import { fileURLToPath } from 'node:url'
6
6
 
7
7
  const here = (path: string) => fileURLToPath(new URL(path, import.meta.url))
8
8
 
9
- // Icons the layer's own components use. They go into the client bundle; icon names in the site's content
10
- // (the `icon` field) are found by the scan. No icon API: everything is bundled, so static hosting works offline.
11
- const icons = [
12
- 'arrow-left', 'arrow-right', 'arrow-up', 'arrow-up-right', 'check', 'chevron-down', 'chevron-right', 'circle-alert',
13
- 'copy', 'corner-down-left', 'file-text', 'hash', 'languages', 'menu', 'moon', 'panel-left', 'pencil', 'rss',
14
- 'search', 'sun', 'text-align-start', 'x',
15
- ].map((name) => `lucide:${name}`)
16
-
17
9
  export default defineNuxtConfig({
18
- modules: ['@lupinum/ginko-components', '@nuxt/icon', '@lupinum/ginko-docs/pages'],
10
+ modules: ['@lupinum/ginko-components', '@lupinum/ginko-docs/pages', '@lupinum/ginko-docs/icons'],
19
11
  // Tells @lupinum/ginko to keep each page's repository path (`source`) in the served bundle, for "Edit this page".
20
12
  // A config hook, not one in modules/pages.ts: it is registered before any module runs, whatever the module order.
21
13
  hooks: { 'ginko:files': (options) => { options.keepSource = true } },
22
14
  css: [here('./app/assets/docs.css')],
23
- icon: {
24
- provider: 'none',
25
- fallbackToApi: false,
26
- customCollections: [{ prefix: 'docs', dir: here('./icons') }],
27
- clientBundle: { icons, scan: true, includeCustomCollections: true },
28
- },
29
15
  // The diagram engine (mermaid + ELK, 462 kB gzip) is loaded only by a page with a ```mermaid block. Left alone, Rolldown
30
- // put its small shared module helper into that chunk, the icon runtime on every page imported the helper from there,
31
- // and so every page downloaded the engine. A named group of its own keeps the helper out of it (e2e/docs.spec.ts checks).
16
+ // could put its small shared module helper into that chunk, and every page importing the helper downloaded the engine. A named group of its own keeps the helper out of it (e2e/docs.spec.ts checks).
32
17
  vite: {
33
18
  build: {
34
19
  rolldownOptions: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lupinum/ginko-docs",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Docs sites on Ginko: a Nuxt layer with navigation, search, blog, changelog and Markdown components.",
6
6
  "license": "MIT",
@@ -35,7 +35,8 @@
35
35
  "types": "./dist/config.d.mts",
36
36
  "import": "./dist/config.mjs"
37
37
  },
38
- "./pages": "./dist/pages.mjs"
38
+ "./pages": "./dist/pages.mjs",
39
+ "./icons": "./dist/icons.mjs"
39
40
  },
40
41
  "publishConfig": {
41
42
  "access": "public"
@@ -43,23 +44,23 @@
43
44
  "dependencies": {
44
45
  "@comark/vue": "0.7.0",
45
46
  "@iconify-json/lucide": "^1.2.137",
46
- "@lupinum/ginko-components": "^0.4.0",
47
- "@nuxt/icon": "^2.5.1",
47
+ "@iconify/utils": "^3.1.7",
48
+ "@lupinum/ginko-components": "^0.5.0",
48
49
  "@nuxt/kit": "^4.6.0",
49
50
  "beautiful-mermaid": "^1.1.3",
50
51
  "katex": "^0.18.11"
51
52
  },
52
53
  "peerDependencies": {
53
- "@lupinum/ginko": "^0.4.0",
54
- "@lupinum/ginko-content": "^0.4.0",
55
- "@lupinum/ginko-i18n": "^0.4.0",
54
+ "@lupinum/ginko": "^0.5.0",
55
+ "@lupinum/ginko-content": "^0.5.0",
56
+ "@lupinum/ginko-i18n": "^0.5.0",
56
57
  "nuxt": "^4.6.0 || ^5.0.0-0",
57
58
  "vue": "^3.5.0"
58
59
  },
59
60
  "devDependencies": {
60
- "@lupinum/ginko": "0.4.0",
61
- "@lupinum/ginko-content": "0.4.0",
62
- "@lupinum/ginko-i18n": "0.4.0",
61
+ "@lupinum/ginko": "0.5.0",
62
+ "@lupinum/ginko-content": "0.5.0",
63
+ "@lupinum/ginko-i18n": "0.5.0",
63
64
  "nuxt": "4.6.0",
64
65
  "unbuild": "3.6.1",
65
66
  "vue": "3.5.43"
@@ -6,7 +6,7 @@ import fileBundles from '#ginko/files'
6
6
  import locales from '#ginko/locales'
7
7
  import type { Brick } from '@lupinum/ginko'
8
8
  import { isVisible, todayIn } from '@lupinum/ginko/internal'
9
- import { rssXml } from './rss'
9
+ import { rssXml } from '@lupinum/ginko/basics'
10
10
 
11
11
  export function serveBlogFeed(event: RequestEvent, locale: string) {
12
12
  const config = useRuntimeConfig().public.ginko as { name: string; url: string; locales: string[]; timeZone: string; seo: { description?: string } }
@@ -0,0 +1,66 @@
1
+ // Which pages and feeds the layer adds for a site, decided from the site's Ginko config and the paths its own pages
2
+ // already take. Pure, so modules/pages.ts only wires the result into Nuxt and the test can check every case.
3
+ export interface SiteOptions { locales?: readonly string[], pages?: Record<string, Record<string, string> | undefined>, bricks?: Record<string, { route?: Record<string, string> } | undefined> }
4
+
5
+ export interface LayerPage { name: string, path: string, /** In `app/docs-pages/`. */ file: string }
6
+
7
+ export interface LayerRoutes {
8
+ docs: { base: string }
9
+ /** Where the bricks put the blog list and the changelog (main language), whoever serves them. The app links here. */
10
+ addresses: { blog?: string, changelog?: string }
11
+ /** The pages the layer adds: each only where the site has no page of its own. */
12
+ pages: LayerPage[]
13
+ /** The blog and changelog the layer serves itself. Feeds are `<blog>/rss.xml` and `/:locale<blog>/rss.xml`. */
14
+ blog?: string
15
+ changelog?: string
16
+ /** A brick is configured but has no address to put a page at (the changelog brick without a route or `pages.changelog`). */
17
+ noAddress: string[]
18
+ }
19
+
20
+ /** The brick's address in the main language, with `[slug]` as `:slug()`; the list page is the parent path. */
21
+ function listPath(route: string | undefined): string | undefined {
22
+ if (!route) return undefined
23
+ const list = route.replace(/\/\[slug\]$/, '')
24
+ return list.includes('[') ? undefined : list || '/'
25
+ }
26
+
27
+ /** A page as Nuxt has it once every `pages:extend` ran: its scanned path and, from the templates' page-paths module, its addresses per language. */
28
+ export interface PageLike { path: string, meta?: { i18n?: false | { paths?: Record<string, string> } } | undefined }
29
+
30
+ /** The addresses a page answers at in the main language: its path and `meta.i18n.paths[main]` when set (`/news` answers at `/blog`). */
31
+ function addressesOf(page: PageLike, main: string): string[] {
32
+ const mapped = page.meta?.i18n && page.meta.i18n.paths?.[main]
33
+ return mapped ? [page.path, mapped] : [page.path]
34
+ }
35
+
36
+ export function layerRoutes(site: SiteOptions | undefined, pages: Iterable<PageLike>): LayerRoutes {
37
+ const bricks = site?.bricks ?? {}
38
+ const main = site?.locales?.[0] ?? ''
39
+ const taken = new Set<string>()
40
+ for (const page of pages) for (const path of addressesOf(page, main)) taken.add(path)
41
+ // docsCollection writes `<base>/[slug]` for the main language; other languages get a prefix from ginko-i18n.
42
+ const docsBase = bricks.docs?.route?.[main]?.replace(/\/\[slug\]$/, '') ?? '/docs'
43
+ const blog = bricks.posts ? listPath(bricks.posts.route?.[main]) : undefined
44
+ // The changelog has no page of its own per entry: its list lives at the route the brick has, else at `pages.changelog`.
45
+ const changelog = bricks.changelog ? listPath(bricks.changelog.route?.[main]) ?? site?.pages?.changelog?.[main] : undefined
46
+ const free = (path: string | undefined) => (path && !taken.has(path) ? path : undefined)
47
+ const noAddress = [...(bricks.posts && !blog ? ['posts'] : []), ...(bricks.changelog && !changelog ? ['changelog'] : [])]
48
+ const addedBlog = free(blog)
49
+ const addedChangelog = free(changelog)
50
+ const wanted: Array<LayerPage | undefined> = [
51
+ { name: 'docs-slug', path: `${docsBase}/:slug()`, file: 'doc.vue' },
52
+ { name: 'docs', path: docsBase, file: 'docs.vue' },
53
+ { name: 'index', path: '/', file: 'index.vue' },
54
+ addedBlog ? { name: 'blog', path: addedBlog, file: 'blog.vue' } : undefined,
55
+ addedBlog ? { name: 'blog-slug', path: `${addedBlog}/:slug()`, file: 'post.vue' } : undefined,
56
+ addedChangelog ? { name: 'changelog', path: addedChangelog, file: 'changelog.vue' } : undefined,
57
+ ]
58
+ return {
59
+ docs: { base: docsBase },
60
+ addresses: { ...(blog && { blog }), ...(changelog && { changelog }) },
61
+ pages: wanted.filter((page): page is LayerPage => !!page && !taken.has(page.path)),
62
+ ...(addedBlog && { blog: addedBlog }),
63
+ ...(addedChangelog && { changelog: addedChangelog }),
64
+ noAddress,
65
+ }
66
+ }
@@ -1,4 +0,0 @@
1
- const BLOG_PATH = "/blog";
2
- const CHANGELOG_PATH = "/changelog";
3
-
4
- export { BLOG_PATH as B, CHANGELOG_PATH as C };
package/server/lib/rss.ts DELETED
@@ -1,57 +0,0 @@
1
- // An RSS 2.0 document as a pure function, so it is unit tested without Nitro.
2
-
3
- export interface FeedItem {
4
- title: string
5
- /** Absolute. */
6
- link: string
7
- description?: string
8
- /** `YYYY-MM-DD`. */
9
- date: string
10
- authors: string[]
11
- }
12
-
13
- export interface Feed {
14
- title: string
15
- /** Absolute address of the page the feed belongs to. */
16
- link: string
17
- /** Absolute address of the feed itself. */
18
- self: string
19
- description: string
20
- language: string
21
- items: FeedItem[]
22
- }
23
-
24
- const escapeXml = (value: string) => value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;')
25
- /** RFC 822 date (what RSS 2.0 asks for) for a day; the day counts as midnight UTC. */
26
- const rfc822 = (day: string) => new Date(`${day}T00:00:00Z`).toUTCString()
27
- const tag = (name: string, value: string) => `<${name}>${escapeXml(value)}</${name}>`
28
-
29
- /** Title and description only, as plain text: the feed links to the page for the full post. */
30
- export function rssXml(feed: Feed): string {
31
- const items = feed.items.map((item) => [
32
- ' <item>',
33
- ` ${tag('title', item.title)}`,
34
- ` ${tag('link', item.link)}`,
35
- ` <guid isPermaLink="true">${escapeXml(item.link)}</guid>`,
36
- ` ${tag('pubDate', rfc822(item.date))}`,
37
- ...(item.description ? [` ${tag('description', item.description)}`] : []),
38
- ...item.authors.map((a) => ` ${tag('dc:creator', a)}`),
39
- ' </item>',
40
- ].join('\n'))
41
- const newest = feed.items.map((i) => i.date).sort().at(-1)
42
- return [
43
- '<?xml version="1.0" encoding="UTF-8"?>',
44
- '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">',
45
- ' <channel>',
46
- ` ${tag('title', feed.title)}`,
47
- ` ${tag('link', feed.link)}`,
48
- ` ${tag('description', feed.description)}`,
49
- ` ${tag('language', feed.language)}`,
50
- ...(newest ? [` ${tag('lastBuildDate', rfc822(newest))}`] : []),
51
- ` <atom:link href="${escapeXml(feed.self)}" rel="self" type="application/rss+xml"/>`,
52
- ...items,
53
- ' </channel>',
54
- '</rss>',
55
- '',
56
- ].join('\n')
57
- }