@docsector/docsector-reader 0.6.0 → 0.7.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/README.md CHANGED
@@ -23,6 +23,8 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
23
23
  - 📝 **Markdown Rendering** — Write docs in Markdown, rendered with syntax highlighting (Prism.js)
24
24
  - 🧩 **Mermaid Diagrams** — Native support for fenced ` ```mermaid ` blocks, with automatic dark/light theme switching
25
25
  - 🚨 **GitHub-Style Alerts** — Native support for `[!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, and `[!CAUTION]`
26
+ - 🤖 **AI-Friendly** — "Copy page" button copies raw Markdown for LLMs; "View as Markdown" opens any page as plain text via `.md` URL suffix
27
+ - 📅 **Last Updated Date** — Automatic per-page "last updated" date from git commit history, locale-formatted
26
28
  - 🌍 **Internationalization (i18n)** — Multi-language support with HJSON locale files and per-page translations
27
29
  - 🌗 **Dark/Light Mode** — Automatic theme switching with Quasar Dark Plugin
28
30
  - 🔗 **Anchor Navigation** — Right-side Table of Contents tree with scroll tracking
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "A documentation rendering engine built with Vue 3, Quasar v2 and Vite. Transform Markdown into beautiful, navigable documentation sites.",
5
5
  "productName": "Docsector Reader",
6
6
  "author": "Rodrigo de Araujo Vieira",
@@ -0,0 +1,135 @@
1
+ <script setup>
2
+ import { ref, computed } from 'vue'
3
+ import { useRoute } from 'vue-router'
4
+ import { useStore } from 'vuex'
5
+ import { useI18n } from 'vue-i18n'
6
+ import { copyToClipboard, openURL } from 'quasar'
7
+
8
+ import gitDates from 'virtual:docsector-git-dates'
9
+
10
+ const store = useStore()
11
+ const route = useRoute()
12
+ const { t, locale } = useI18n()
13
+
14
+ const copied = ref(false)
15
+
16
+ const subpage = computed(() => {
17
+ const rel = store.state.page.relative
18
+ return rel ? rel.replace(/^\//, '') : 'overview'
19
+ })
20
+
21
+ const fileKey = computed(() => {
22
+ const base = store.state.page.base
23
+ if (!base) return ''
24
+ return `${base}.${subpage.value}.${locale.value}.md`
25
+ })
26
+
27
+ const formattedDate = computed(() => {
28
+ const iso = gitDates[fileKey.value]
29
+ if (!iso) return ''
30
+
31
+ const date = new Date(iso)
32
+ if (isNaN(date.getTime())) return ''
33
+
34
+ return new Intl.DateTimeFormat(locale.value, {
35
+ year: 'numeric',
36
+ month: 'long',
37
+ day: 'numeric'
38
+ }).format(date)
39
+ })
40
+
41
+ const rawMarkdown = computed(() => {
42
+ const absolute = store.state.i18n.absolute
43
+ if (!absolute) return ''
44
+
45
+ const source = t(`_.${absolute}.source`)
46
+ if (!source) return ''
47
+
48
+ return String(source)
49
+ .replace(/&#123;/g, '{')
50
+ .replace(/&#125;/g, '}')
51
+ .replace(/\{'([^']+)'\}/g, '$1')
52
+ .replace(/&amp;/g, '&')
53
+ })
54
+
55
+ const markdownURL = computed(() => {
56
+ return `${route.path}.md`
57
+ })
58
+
59
+ const copyPage = () => {
60
+ if (!rawMarkdown.value) return
61
+
62
+ copyToClipboard(rawMarkdown.value).then(() => {
63
+ copied.value = true
64
+ setTimeout(() => { copied.value = false }, 2000)
65
+ })
66
+ }
67
+
68
+ const viewAsMarkdown = () => {
69
+ openURL(markdownURL.value)
70
+ }
71
+ </script>
72
+
73
+ <template>
74
+ <div class="d-page-bar">
75
+ <span v-if="formattedDate" class="d-page-bar__date">
76
+ {{ t('page.lastUpdated') }}: {{ formattedDate }}
77
+ </span>
78
+ <span v-else class="d-page-bar__date"></span>
79
+
80
+ <q-btn-dropdown
81
+ class="d-page-bar__actions"
82
+ split
83
+ no-caps
84
+ :icon="copied ? 'check' : 'content_copy'"
85
+ :label="copied ? t('page.copied') : t('page.copyPage')"
86
+ :color="copied ? 'positive' : 'grey-7'"
87
+ size="sm"
88
+ @click="copyPage"
89
+ >
90
+ <q-list style="min-width: 240px">
91
+ <q-item clickable v-close-popup @click="copyPage" class="q-py-sm">
92
+ <q-item-section avatar>
93
+ <q-icon name="content_copy" />
94
+ </q-item-section>
95
+ <q-item-section>
96
+ <q-item-label>{{ t('page.copyPage') }}</q-item-label>
97
+ <q-item-label caption>{{ t('page.copyPageCaption') }}</q-item-label>
98
+ </q-item-section>
99
+ </q-item>
100
+
101
+ <q-item clickable v-close-popup @click="viewAsMarkdown" class="q-py-sm">
102
+ <q-item-section avatar>
103
+ <q-icon name="description" />
104
+ </q-item-section>
105
+ <q-item-section>
106
+ <q-item-label>{{ t('page.viewAsMarkdown') }}</q-item-label>
107
+ <q-item-label caption>{{ t('page.viewAsMarkdownCaption') }}</q-item-label>
108
+ </q-item-section>
109
+ <q-item-section side>
110
+ <q-icon name="open_in_new" size="xs" />
111
+ </q-item-section>
112
+ </q-item>
113
+ </q-list>
114
+ </q-btn-dropdown>
115
+ </div>
116
+ </template>
117
+
118
+ <style lang="sass">
119
+ .d-page-bar
120
+ display: flex
121
+ justify-content: space-between
122
+ align-items: center
123
+ margin-bottom: 4px
124
+
125
+ &__date
126
+ font-size: 0.8rem
127
+ opacity: 0.6
128
+
129
+ &__actions
130
+ font-size: 0.75rem
131
+
132
+ body.body--dark
133
+ .d-page-bar__date
134
+ color: rgba(255, 255, 255, 0.7)
135
+ </style>
@@ -3,6 +3,7 @@ import { computed } from 'vue'
3
3
  import { useRoute } from 'vue-router'
4
4
  // components
5
5
  import DPage from "./DPage.vue";
6
+ import DPageBar from "./DPageBar.vue";
6
7
  import DH1 from "./DH1.vue";
7
8
  import DPageSection from "./DPageSection.vue";
8
9
 
@@ -23,6 +24,8 @@ const id = computed(() => {
23
24
  <template>
24
25
  <d-page>
25
26
  <header>
27
+ <d-page-bar />
28
+ <hr />
26
29
  <d-h1 :id="0" />
27
30
  </header>
28
31
 
@@ -16,6 +16,51 @@
16
16
  * export default buildMessages({ langModules, mdModules, pages, boot })
17
17
  */
18
18
 
19
+ /**
20
+ * Engine default i18n keys, keyed by locale.
21
+ * These are deep-merged into consumer messages so engine components
22
+ * always have their required translations available.
23
+ */
24
+ const engineDefaults = {
25
+ 'en-US': {
26
+ page: {
27
+ lastUpdated: 'Last updated',
28
+ copyPage: 'Copy page',
29
+ copyPageCaption: 'Copy page as Markdown for LLMs',
30
+ copied: 'Copied!',
31
+ viewAsMarkdown: 'View as Markdown',
32
+ viewAsMarkdownCaption: 'View this page as plain text'
33
+ }
34
+ },
35
+ 'pt-BR': {
36
+ page: {
37
+ lastUpdated: 'Última atualização',
38
+ copyPage: 'Copiar página',
39
+ copyPageCaption: 'Copiar página como Markdown para LLMs',
40
+ copied: 'Copiado!',
41
+ viewAsMarkdown: 'Ver como Markdown',
42
+ viewAsMarkdownCaption: 'Ver esta página como texto simples'
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Deep-merge source into target (target values take precedence).
49
+ */
50
+ function deepMerge (target, source) {
51
+ for (const key of Object.keys(source)) {
52
+ if (
53
+ source[key] && typeof source[key] === 'object' && !Array.isArray(source[key]) &&
54
+ target[key] && typeof target[key] === 'object' && !Array.isArray(target[key])
55
+ ) {
56
+ deepMerge(target[key], source[key])
57
+ } else if (!(key in target)) {
58
+ target[key] = source[key]
59
+ }
60
+ }
61
+ return target
62
+ }
63
+
19
64
  /**
20
65
  * Escape characters that conflict with vue-i18n message syntax.
21
66
  *
@@ -77,6 +122,11 @@ export function buildMessages ({ langModules, mdModules, pages, boot, langs }) {
77
122
  const langKey = `./languages/${lang}.hjson`
78
123
  i18n[lang] = langModules[langKey]?.default || langModules[langKey] || {}
79
124
 
125
+ // Merge engine defaults (consumer values take precedence)
126
+ if (engineDefaults[lang]) {
127
+ deepMerge(i18n[lang], engineDefaults[lang])
128
+ }
129
+
80
130
  // @ Iterate pages
81
131
  for (const [key, page] of Object.entries(pages)) {
82
132
  const path = key.slice(1)
@@ -30,7 +30,13 @@
30
30
  nav: {
31
31
  prev: 'Previous page',
32
32
  next: 'Next page'
33
- }
33
+ },
34
+ lastUpdated: 'Last updated',
35
+ copyPage: 'Copy page',
36
+ copyPageCaption: 'Copy page as Markdown for LLMs',
37
+ copied: 'Copied!',
38
+ viewAsMarkdown: 'View as Markdown',
39
+ viewAsMarkdownCaption: 'View this page as plain text'
34
40
  },
35
41
 
36
42
  menu: {
@@ -29,7 +29,13 @@
29
29
  nav: {
30
30
  prev: 'Página anterior',
31
31
  next: 'Próxima página'
32
- }
32
+ },
33
+ lastUpdated: 'Última atualização',
34
+ copyPage: 'Copiar página',
35
+ copyPageCaption: 'Copiar página como Markdown para LLMs',
36
+ copied: 'Copiado!',
37
+ viewAsMarkdown: 'Ver como Markdown',
38
+ viewAsMarkdownCaption: 'Ver esta página como texto simples'
33
39
  },
34
40
 
35
41
  menu: {
@@ -23,7 +23,8 @@
23
23
  * @param {Function} [options.extendViteConf] - Additional Vite config extension
24
24
  */
25
25
 
26
- import { readFileSync, existsSync, rmSync, mkdirSync, writeFileSync } from 'fs'
26
+ import { readFileSync, existsSync, rmSync, mkdirSync, writeFileSync, readdirSync } from 'fs'
27
+ import { execSync } from 'child_process'
27
28
  import { createHash } from 'crypto'
28
29
  import { resolve } from 'path'
29
30
  import { pathToFileURL } from 'url'
@@ -231,6 +232,193 @@ function createPrerenderMetaPlugin (projectRoot) {
231
232
  }
232
233
  }
233
234
 
235
+ /**
236
+ * Create a Vite plugin that collects git last-commit dates for all Markdown
237
+ * files under src/pages/ and exposes them as a virtual module.
238
+ *
239
+ * Consuming components can `import gitDates from 'virtual:docsector-git-dates'`
240
+ * to get an object mapping relative page keys to ISO date strings.
241
+ *
242
+ * Keys use the pattern: `<type>/<path>.<subpage>.<locale>.md`
243
+ * e.g. `manual/Bootgly/about/what.overview.en-US.md`
244
+ */
245
+ function createGitDatesPlugin (projectRoot) {
246
+ const virtualId = 'virtual:docsector-git-dates'
247
+ const resolvedId = '\0' + virtualId
248
+ let dates = {}
249
+
250
+ function collectDates () {
251
+ dates = {}
252
+ const pagesDir = resolve(projectRoot, 'src', 'pages')
253
+ if (!existsSync(pagesDir)) return
254
+
255
+ const walkDir = (dir) => {
256
+ const entries = readdirSync(dir, { withFileTypes: true })
257
+ for (const entry of entries) {
258
+ const fullPath = resolve(dir, entry.name)
259
+ if (entry.isDirectory()) {
260
+ walkDir(fullPath)
261
+ } else if (entry.name.endsWith('.md')) {
262
+ try {
263
+ const date = execSync(
264
+ `git log -1 --format=%cI -- "${fullPath}"`,
265
+ { cwd: projectRoot, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
266
+ ).trim()
267
+ if (date) {
268
+ // Key relative to src/pages/, e.g. "manual/Bootgly/about/what.overview.en-US.md"
269
+ const relKey = fullPath.slice(pagesDir.length + 1)
270
+ dates[relKey] = date
271
+ }
272
+ } catch {
273
+ // git not available or file untracked — skip
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ walkDir(pagesDir)
280
+ }
281
+
282
+ return {
283
+ name: 'docsector-git-dates',
284
+ buildStart () {
285
+ collectDates()
286
+ },
287
+ resolveId (id) {
288
+ if (id === virtualId) return resolvedId
289
+ },
290
+ load (id) {
291
+ if (id === resolvedId) {
292
+ return `export default ${JSON.stringify(dates)}`
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Create a Vite plugin that serves raw Markdown content for `.md` suffixed URLs.
300
+ *
301
+ * In **dev mode**, intercepts requests like `/manual/Bootgly/about/what/overview.md`
302
+ * and serves the corresponding `src/pages/manual/Bootgly/about/what.overview.<lang>.md`
303
+ * file as `text/plain; charset=utf-8`.
304
+ *
305
+ * In **production build** (`closeBundle`), generates static `.md` files in `dist/spa/`
306
+ * for each page/subpage so that the `.md` URLs resolve to actual files on any static host.
307
+ *
308
+ * The language served is determined by the `?lang=` query parameter, falling back to the
309
+ * `defaultLanguage` from `docsector.config.js`.
310
+ */
311
+ function createMarkdownEndpointPlugin (projectRoot) {
312
+ const pagesDir = resolve(projectRoot, 'src', 'pages')
313
+
314
+ function resolveMarkdownFile (urlPath, lang) {
315
+ // URL: /manual/Bootgly/about/what/overview.md
316
+ // Strip leading slash and trailing .md
317
+ const clean = urlPath.replace(/^\//, '').replace(/\.md$/, '')
318
+ // Split into segments: ['manual', 'Bootgly', 'about', 'what', 'overview']
319
+ const segments = clean.split('/')
320
+ if (segments.length < 2) return null
321
+
322
+ // Last segment is the subpage (overview, showcase, vs)
323
+ const subpage = segments.pop()
324
+ // Remaining segments form the type + path: 'manual/Bootgly/about/what'
325
+ const basePath = segments.join('/')
326
+
327
+ // File: src/pages/manual/Bootgly/about/what.overview.en-US.md
328
+ const filePath = resolve(pagesDir, `${basePath}.${subpage}.${lang}.md`)
329
+ if (existsSync(filePath)) return filePath
330
+
331
+ return null
332
+ }
333
+
334
+ return {
335
+ name: 'docsector-markdown-endpoint',
336
+
337
+ configureServer (server) {
338
+ // Read default language from config
339
+ let defaultLang = 'en-US'
340
+ try {
341
+ const configPath = resolve(projectRoot, 'docsector.config.js')
342
+ if (existsSync(configPath)) {
343
+ // Dynamic import in dev — we read it synchronously via a simple approach
344
+ const configContent = readFileSync(configPath, 'utf-8')
345
+ const match = configContent.match(/defaultLanguage\s*:\s*['"]([^'"]+)['"]/)
346
+ if (match) defaultLang = match[1]
347
+ }
348
+ } catch { /* use fallback */ }
349
+
350
+ server.middlewares.use((req, res, next) => {
351
+ const url = new URL(req.url, 'http://localhost')
352
+ if (!url.pathname.endsWith('.md')) return next()
353
+
354
+ const lang = url.searchParams.get('lang') || defaultLang
355
+ const file = resolveMarkdownFile(url.pathname, lang)
356
+ if (!file) return next()
357
+
358
+ const content = readFileSync(file, 'utf-8')
359
+ res.setHeader('Content-Type', 'text/plain; charset=utf-8')
360
+ res.end(content)
361
+ })
362
+ },
363
+
364
+ apply: 'serve'
365
+ }
366
+ }
367
+
368
+ /**
369
+ * Create a Vite plugin that generates static `.md` files at build time.
370
+ *
371
+ * Runs in the `closeBundle` hook alongside the prerender-meta plugin.
372
+ * For each page/subpage, copies the raw Markdown source into
373
+ * `dist/spa/<routePath>.md` so that `.md` URLs work on static hosts.
374
+ */
375
+ function createMarkdownBuildPlugin (projectRoot) {
376
+ return {
377
+ name: 'docsector-markdown-build',
378
+ apply: 'build',
379
+ async closeBundle () {
380
+ const distDir = resolve(projectRoot, 'dist', 'spa')
381
+ if (!existsSync(distDir)) return
382
+
383
+ const pagesDir = resolve(projectRoot, 'src', 'pages')
384
+ const configUrl = pathToFileURL(resolve(projectRoot, 'docsector.config.js')).href
385
+ const pagesUrl = pathToFileURL(resolve(projectRoot, 'src', 'pages', 'index.js')).href
386
+
387
+ const { default: config } = await import(configUrl)
388
+ const { default: pages } = await import(pagesUrl)
389
+
390
+ const defaultLang = config.defaultLanguage || config.languages?.[0]?.value || 'en-US'
391
+ let count = 0
392
+
393
+ for (const [pagePath, page] of Object.entries(pages)) {
394
+ if (page.config === null) continue
395
+ if (page.config.status === 'empty') continue
396
+
397
+ const type = page.config.type ?? 'manual'
398
+
399
+ const subpages = ['overview']
400
+ if (page.config.subpages?.showcase) subpages.push('showcase')
401
+ if (page.config.subpages?.vs) subpages.push('vs')
402
+
403
+ for (const subpage of subpages) {
404
+ const srcFile = resolve(pagesDir, `${type}${pagePath}.${subpage}.${defaultLang}.md`)
405
+ if (!existsSync(srcFile)) continue
406
+
407
+ const routePath = `${type}${pagePath}/${subpage}`
408
+ const destFile = resolve(distDir, `${routePath}.md`)
409
+ const destDir = resolve(destFile, '..')
410
+
411
+ mkdirSync(destDir, { recursive: true })
412
+ writeFileSync(destFile, readFileSync(srcFile, 'utf-8'))
413
+ count++
414
+ }
415
+ }
416
+
417
+ console.log(`\x1b[36m[docsector]\x1b[0m Generated ${count} static .md files`)
418
+ }
419
+ }
420
+ }
421
+
234
422
  /**
235
423
  * Create a complete Quasar configuration for a docsector-reader consumer project.
236
424
  *
@@ -301,6 +489,9 @@ export function createQuasarConfig (options = {}) {
301
489
 
302
490
  vitePlugins: [
303
491
  createHjsonPlugin(),
492
+ createGitDatesPlugin(projectRoot),
493
+ createMarkdownEndpointPlugin(projectRoot),
494
+ createMarkdownBuildPlugin(projectRoot),
304
495
  createPagesWatchPlugin(projectRoot),
305
496
  createPrerenderMetaPlugin(projectRoot),
306
497
  ...vitePlugins