@sewadah/selaras-docs 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.3.0
4
+
5
+ [compare changes](https://github.com/sewadahapp/selaras/compare/selaras-docs-v1.2.4...selaras-docs-v1.3.0)
6
+
7
+ ### 🚀 Enhancements
8
+
9
+ - **feat(docs):** add logo to footer attribution ([1af91a6](https://github.com/sewadahapp/selaras/commit/1af91a6b66ac279255a0a6ca421dda89ee6c966e))
10
+ - **feat(docs):** add contextual page contribution actions ([4e21d84](https://github.com/sewadahapp/selaras/commit/4e21d843dc514d6f6f50f0922371fe0cbf94a9c7))
11
+
12
+ ### 🩹 Fixes
13
+
14
+ - **fix(docs):** render an empty state for blank Markdown pages ([bf4cb17](https://github.com/sewadahapp/selaras/commit/bf4cb1746311c10ff389e997aaec867b60fd9a83))
15
+ - **fix(docs):** provide an optional theme picker fallback ([3ba8c5d](https://github.com/sewadahapp/selaras/commit/3ba8c5d2d24d45bab98fdae8163de0338ed11d7c))
16
+ - **fix(docs):** align and refine footer layout ([029956a](https://github.com/sewadahapp/selaras/commit/029956a79b11b6d13610348a3f1bec516036dacb))
17
+
18
+ Published against `@sewadah/selaras` ^0.9.0.
19
+
20
+ ## v1.2.4
21
+
22
+ [compare changes](https://github.com/sewadahapp/selaras/compare/selaras-docs-v1.2.3...selaras-docs-v1.2.4)
23
+
24
+ ### 🚀 Enhancements
25
+
26
+ - **feat(docs):** add interactive application blocks ([102ee68](https://github.com/sewadahapp/selaras/commit/102ee68bef82d12b07dc4788dc8ea2378435e0ea))
27
+ - **feat(docs):** add error pages and local recovery boundaries ([371ed7a](https://github.com/sewadahapp/selaras/commit/371ed7a4fd873fa7e4647ab0b89b85dc0bb7a4b7))
28
+ - **feat(docs):** add SEO metadata and static sharing images ([f6ec141](https://github.com/sewadahapp/selaras/commit/f6ec14152def06f91a62292b53ae2df50813b1d5))
29
+ - **feat(docs):** add Seirama branding and design kit ([9fd9f19](https://github.com/sewadahapp/selaras/commit/9fd9f1948feaa19897ae24e982d33099d50a732c))
30
+ - **feat(forms):** add leading and trailing content slots ([ebd0948](https://github.com/sewadahapp/selaras/commit/ebd094813a274d444319cb0354387c770ab8a6ef))
31
+
32
+ ### 🩹 Fixes
33
+
34
+ - **fix(docs):** configure custom domain deployment ([b65908a](https://github.com/sewadahapp/selaras/commit/b65908a7aa4ad0d0b67029b41c338633e9faa50d))
35
+
36
+ Published against `@sewadah/selaras` ^0.9.0.
37
+
3
38
  ## v1.2.3
4
39
 
5
40
  [compare changes](https://github.com/sewadahapp/selaras/compare/selaras-docs-v1.2.2...selaras-docs-v1.2.3)
package/README.md CHANGED
@@ -57,6 +57,7 @@ export default defineAppConfig({
57
57
  repository: {
58
58
  url: 'https://github.com/example/project',
59
59
  editLinks: true,
60
+ issueLinks: true,
60
61
  },
61
62
  header: {
62
63
  search: true,
@@ -100,12 +101,35 @@ navBadges:
100
101
  Use `navigation: false` in a page's frontmatter to omit it from navigation, or
101
102
  set `navigation` to an object to override its navigation `title`, `icon`,
102
103
  `order`, and `navBadges`. Page frontmatter can also set `toc: false`,
103
- `aside: false`, `editLink: false`, or `prevNext: false` to hide those parts of
104
+ `aside: false`, `editLink: false`, `issueLink: false`, or `prevNext: false` to hide those parts of
104
105
  the page shell. The sidebar also accepts an app-wide `collapsed` default; a
105
106
  folder's `collapse` value takes precedence. `main.fluid` expands the
106
107
  content width, `main.padded: false` removes its outer inset, and `header.fluid`
107
108
  and `footer.fluid` allow those regions to span the viewport.
108
109
 
110
+ ### Page actions
111
+
112
+ With `repository.url` configured, **Edit this page** and **Report an issue**
113
+ appear below the table of contents on desktop. On smaller screens, or when
114
+ the page has no table of contents, they appear after the article and before
115
+ Previous/Next navigation. Pages without an aside use the available content width.
116
+
117
+ Set `repository.branch` and `repository.contentDirectory` to the branch and
118
+ Markdown directory in your repository; their defaults are `main` and `content`.
119
+ Generated issue links include the page title and route in a new issue draft.
120
+ The links open the repository in a new tab; they do not submit changes or issues.
121
+
122
+ Use `repository.editLinks: false` or `repository.issueLinks: false` to hide an
123
+ action site-wide. Set `editLink: false` or `issueLink: false` in frontmatter to
124
+ hide it on one page. GitHub and GitLab URLs are recognized automatically;
125
+ set `repository.issueUrl` to use a different issue tracker or an issue-template
126
+ chooser. Custom issue URLs are used unchanged. With no supported repository or
127
+ custom issue URL, unavailable actions are omitted.
128
+
129
+ Override `components/DocsPageActions.vue` to replace the group, or
130
+ `components/DocsEditLink.vue` to customize the edit link. The
131
+ `DocsTableOfContents` default slot renders content below its heading links.
132
+
109
133
  Create an application component with the same name as a layer component, such
110
134
  as `components/DocsHeader.vue`, to replace that part of the shell. Smaller
111
135
  pieces can be replaced on their own:
@@ -114,11 +138,91 @@ pieces can be replaced on their own:
114
138
  | --- | --- | --- |
115
139
  | `DocsHeaderBrand` | The header's home link content | `site.logo` and the site name |
116
140
  | `DocsFooterBrand` | The start of the footer | `footer.text` |
141
+ | `DocsThemePicker` | Optional header theme control | Empty; provide your own component and set `header.themePicker: true` |
117
142
  | `DocsLogo` | The logo image, used by `DocsHeaderBrand` | `site.logo`, switching light/dark; size it with `--selaras-docs-logo-height` |
118
143
 
119
144
  For example, a `components/DocsFooterBrand.vue` containing `<DocsLogo />` adds
120
145
  the logo to the footer while leaving the header as configured.
121
146
 
147
+ The layer does not include the Selaras site's theme presets. To offer your own
148
+ themes, create `components/DocsThemePicker.vue` and enable
149
+ `selarasDocs.header.themePicker` in `app.config.ts`. The layer's empty default
150
+ keeps the header valid when the consuming site does not supply a picker.
151
+
152
+ ## Error recovery
153
+
154
+ An existing Markdown file with no body content renders a **No content yet**
155
+ view inside the documentation shell, with a themed vector illustration and a
156
+ link back to the documentation. Zero-byte, whitespace-only and frontmatter-only
157
+ pages remain valid pages rather than 404s. Header, navigation, configured page
158
+ actions and Previous/Next remain available. Adding body content replaces the
159
+ empty view automatically.
160
+
161
+ Set `selarasDocs.empty.title` and `selarasDocs.empty.description` in
162
+ `app.config.ts` to change its copy. Its home action uses `errors.homeTo` and
163
+ `errors.homeLabel`, matching the recovery screens. Override
164
+ `components/DocsEmptyState.vue` for a different empty-page design; its
165
+ `illustration` slot defaults to the existing `DocsErrorIllustration` component.
166
+
167
+ ### Errors
168
+
169
+ The layer provides a default `error.vue` for missing pages and fatal errors.
170
+ It preserves the HTTP status code, keeps error pages out of search indexes and
171
+ offers a home link or reload action. The standalone screen avoids loading
172
+ navigation and Content data so it can render when those services fail.
173
+
174
+ Client-side page failures show a local recovery state inside the layout, keeping
175
+ the header and sidebar available. `DocsExample` isolates each live example;
176
+ retrying remounts its content. Navigating to another route clears the page
177
+ boundary. Server rendering errors still use the standalone error screen.
178
+
179
+ Content query failures show a retry state with HTTP 500 during server rendering.
180
+ Only a successful query with no matching document is treated as a 404.
181
+ If a request cannot recover in place, retry reloads the current page to start a
182
+ fresh session. Reloads happen only after the user chooses to retry.
183
+
184
+ Customize the messages and home destination in `app.config.ts`:
185
+
186
+ ```ts
187
+ export default defineAppConfig({
188
+ selarasDocs: {
189
+ errors: {
190
+ homeTo: '/guide/introduction',
191
+ homeLabel: 'Back to the guide',
192
+ retryLabel: 'Try again',
193
+ reloadLabel: 'Reload page',
194
+ retryingLabel: 'Trying again…',
195
+ notFound: { title: 'Page not found', description: 'Start again from the guide.' },
196
+ fatal: { title: 'Something went wrong' },
197
+ page: { title: 'This page couldn’t load' },
198
+ example: { title: 'This example couldn’t load' },
199
+ },
200
+ },
201
+ })
202
+ ```
203
+
204
+ Home destinations are internal routes resolved under the app's `baseURL`.
205
+ The default destination is `/`. Provide an existing page if your site has no
206
+ homepage. Public recovery copy never includes raw exceptions or stack traces.
207
+
208
+ Override `components/DocsErrorIllustration.vue` to replace the default geometric
209
+ document illustration. The optional `missing` boolean distinguishes a 404 from
210
+ other errors. Use inherited theme tokens in inline SVGs for light/dark support.
211
+ `DocsErrorState` also exposes an `illustration` slot. Override `DocsErrorPage`
212
+ for the standalone screen's composition, or supply your own `error.vue` for
213
+ application-specific providers. Applications with their own `app.vue` should
214
+ retain the page boundary inside their layout:
215
+
216
+ ```vue
217
+ <template>
218
+ <NuxtLayout>
219
+ <DocsErrorBoundary>
220
+ <NuxtPage />
221
+ </DocsErrorBoundary>
222
+ </NuxtLayout>
223
+ </template>
224
+ ```
225
+
122
226
  For a host that owns Tailwind compilation or uses a class prefix, set
123
227
  `selarasDocs: { css: false }` in `nuxt.config.ts` and import Selaras and the
124
228
  docs stylesheet from the host CSS entry.
package/app/app.config.ts CHANGED
@@ -7,6 +7,7 @@ export default defineAppConfig({
7
7
  branch: 'main',
8
8
  contentDirectory: 'content',
9
9
  editLinks: true,
10
+ issueLinks: true,
10
11
  },
11
12
  header: {
12
13
  showTitle: true,
@@ -31,7 +32,7 @@ export default defineAppConfig({
31
32
  links: [
32
33
  {
33
34
  label: 'Powered by Selaras',
34
- to: 'https://sewadahapp.github.io/selaras',
35
+ to: 'https://selarasui.my.id',
35
36
  target: '_blank',
36
37
  rel: 'noreferrer',
37
38
  },
package/app/app.vue CHANGED
@@ -13,7 +13,9 @@ useHead(() => {
13
13
  <template>
14
14
  <SApp>
15
15
  <NuxtLayout>
16
- <NuxtPage />
16
+ <DocsErrorBoundary>
17
+ <NuxtPage />
18
+ </DocsErrorBoundary>
17
19
  </NuxtLayout>
18
20
  <ClientOnly>
19
21
  <DocsSearch />
@@ -117,6 +117,10 @@
117
117
  gap: 3rem;
118
118
  }
119
119
 
120
+ .selaras-docs-page-grid.selaras-docs-page-grid--wide {
121
+ grid-template-columns: minmax(0, 1fr);
122
+ }
123
+
120
124
  .selaras-docs-article {
121
125
  min-width: 0;
122
126
  }
@@ -133,21 +137,53 @@
133
137
  width: 14rem;
134
138
  }
135
139
 
136
- .selaras-docs-edit-link {
140
+ .selaras-docs-page-actions {
141
+ display: flex;
142
+ flex-direction: column;
143
+ gap: 0.25rem;
144
+ margin-top: 1.5rem;
145
+ padding-top: 1rem;
146
+ border-top: 1px solid var(--selaras-resolved-border-default);
147
+ }
148
+
149
+ .selaras-docs-article > .selaras-docs-page-actions {
150
+ margin-top: 2.5rem;
151
+ }
152
+
153
+ .selaras-docs-edit-link,
154
+ .selaras-docs-issue-link {
137
155
  display: inline-flex;
156
+ width: fit-content;
157
+ min-height: 2rem;
138
158
  align-items: center;
139
- gap: 0.375rem;
140
- margin-top: 2.5rem;
159
+ gap: 0.5rem;
141
160
  color: var(--selaras-resolved-text-muted);
142
- font-size: 0.875rem;
161
+ font-size: 0.8125rem;
162
+ line-height: 1.5;
143
163
  text-decoration: none;
164
+ text-underline-offset: 0.25em;
165
+ }
166
+
167
+ .selaras-docs-edit-link .iconify,
168
+ .selaras-docs-issue-link .iconify {
169
+ width: 1rem;
170
+ height: 1rem;
171
+ flex-shrink: 0;
144
172
  }
145
173
 
146
- .selaras-docs-edit-link:hover {
174
+ .selaras-docs-edit-link:hover,
175
+ .selaras-docs-issue-link:hover {
147
176
  color: var(--selaras-resolved-text-default);
148
177
  text-decoration: underline;
149
178
  }
150
179
 
180
+ .selaras-docs-edit-link:focus-visible,
181
+ .selaras-docs-issue-link:focus-visible {
182
+ outline: 2px solid var(--selaras-resolved-color-primary-focus);
183
+ outline-offset: 3px;
184
+ border-radius: var(--selaras-resolved-radius-sm);
185
+ }
186
+
151
187
  .selaras-docs-surround {
152
188
  margin-top: 2rem;
153
189
  }
@@ -219,6 +255,13 @@
219
255
  background: var(--selaras-resolved-surface-elevated);
220
256
  }
221
257
 
258
+ .selaras-docs-footer-attribution-logo {
259
+ width: 1rem;
260
+ height: 1rem;
261
+ flex-shrink: 0;
262
+ color: var(--selaras-resolved-color-primary-fill);
263
+ }
264
+
222
265
  .selaras-docs-footer-links a:focus-visible {
223
266
  outline: 2px solid var(--selaras-resolved-color-primary-fill);
224
267
  outline-offset: 2px;
@@ -231,6 +274,10 @@
231
274
  }
232
275
 
233
276
  @media (min-width: 64rem) {
277
+ .selaras-docs-page-actions--mobile {
278
+ display: none;
279
+ }
280
+
234
281
  .selaras-docs-nav-toggle {
235
282
  display: none;
236
283
  }
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import { getDocsRepositoryLinks } from '../utils/repository-links'
3
+
2
4
  const props = defineProps<{
3
5
  path: string
4
6
  }>()
@@ -6,26 +8,12 @@ const props = defineProps<{
6
8
  const appConfig = useAppConfig()
7
9
  const docsConfig = computed(() => appConfig.selarasDocs ?? {})
8
10
 
9
- const editUrl = computed(() => {
10
- const repository = docsConfig.value.repository
11
- if (!repository?.url || repository.editLinks === false)
12
- return undefined
13
-
14
- const base = repository.url.replace(/\/$/, '')
15
- const relativePath = props.path === '/' ? 'index.md' : `${props.path.replace(/^\//, '')}.md`
16
- const contentPath = [repository.contentDirectory ?? 'content', relativePath].join('/')
17
- const branch = repository.branch ?? 'main'
18
-
19
- if (base.startsWith('https://github.com/'))
20
- return `${base}/edit/${branch}/${contentPath}`
21
- if (base.startsWith('https://gitlab.com/'))
22
- return `${base}/-/edit/${branch}/${contentPath}`
23
- return undefined
24
- })
11
+ const editUrl = computed(() => getDocsRepositoryLinks(docsConfig.value.repository, props.path).edit)
25
12
  </script>
26
13
 
27
14
  <template>
28
15
  <a v-if="editUrl" :href="editUrl" class="selaras-docs-edit-link" target="_blank" rel="noreferrer">
16
+ <SIcon name="hugeicons:pencil-edit-01" aria-hidden="true" />
29
17
  Edit this page
30
18
  </a>
31
19
  </template>
@@ -0,0 +1,72 @@
1
+ <script setup lang="ts">
2
+ withDefaults(defineProps<{ heading?: 'h1' | 'h2' }>(), { heading: 'h1' })
3
+
4
+ const appConfig = useAppConfig()
5
+ const config = useRuntimeConfig()
6
+ const headingId = useId()
7
+ const copy = computed(() => appConfig.selarasDocs?.empty ?? {})
8
+ const homeHref = computed(() => {
9
+ const path = appConfig.selarasDocs?.errors?.homeTo ?? '/'
10
+ return `${config.app.baseURL.replace(/\/$/, '')}/${path.replace(/^\//, '')}`
11
+ })
12
+ </script>
13
+
14
+ <template>
15
+ <section class="selaras-docs-empty-state not-prose" data-docs-empty-state :aria-labelledby="headingId">
16
+ <div class="selaras-docs-empty-artwork">
17
+ <slot name="illustration">
18
+ <DocsErrorIllustration missing />
19
+ </slot>
20
+ </div>
21
+ <component :is="heading" :id="headingId" class="selaras-docs-empty-title">
22
+ {{ copy.title ?? 'No content yet' }}
23
+ </component>
24
+ <p class="selaras-docs-empty-description">
25
+ {{ copy.description ?? 'There’s no content on this page yet. You can explore the rest of the documentation using the navigation.' }}
26
+ </p>
27
+ <SButton as="a" :href="homeHref" variant="outline" color="neutral" class="selaras-docs-empty-action">
28
+ {{ appConfig.selarasDocs?.errors?.homeLabel ?? 'Back to documentation' }}
29
+ </SButton>
30
+ </section>
31
+ </template>
32
+
33
+ <style scoped>
34
+ .selaras-docs-empty-state {
35
+ max-width: 32rem;
36
+ margin: 3rem auto;
37
+ color: var(--selaras-resolved-text-default);
38
+ text-align: center;
39
+ }
40
+
41
+ .selaras-docs-empty-artwork {
42
+ width: min(100%, 18rem);
43
+ margin: 0 auto 1.5rem;
44
+ }
45
+
46
+ .selaras-docs-empty-artwork :deep(svg) {
47
+ display: block;
48
+ width: 100%;
49
+ height: auto;
50
+ }
51
+
52
+ .selaras-docs-empty-title {
53
+ margin: 0;
54
+ font-size: clamp(1.5rem, 1.25rem + 1vw, 2rem);
55
+ font-weight: 600;
56
+ line-height: 1.25;
57
+ letter-spacing: -0.025em;
58
+ text-wrap: balance;
59
+ }
60
+
61
+ .selaras-docs-empty-description {
62
+ max-width: 42ch;
63
+ margin: 0.875rem auto 0;
64
+ color: var(--selaras-resolved-text-muted);
65
+ line-height: 1.7;
66
+ text-wrap: pretty;
67
+ }
68
+
69
+ .selaras-docs-empty-action {
70
+ margin-top: 1.5rem;
71
+ }
72
+ </style>
@@ -0,0 +1,27 @@
1
+ <script setup lang="ts">
2
+ import type { SelarasDocsErrorKind } from '../../types'
3
+ import { useTemplateRef, watch } from 'vue'
4
+ import { useRouter } from '#app'
5
+
6
+ const props = withDefaults(defineProps<{
7
+ kind?: Extract<SelarasDocsErrorKind, 'page' | 'example'>
8
+ resetKey?: string
9
+ }>(), { kind: 'page' })
10
+
11
+ const boundary = useTemplateRef('boundary')
12
+ const router = useRouter()
13
+ watch([() => router.currentRoute.value.fullPath, () => props.resetKey], () => boundary.value?.clearError())
14
+
15
+ function isMissing(error: Error) {
16
+ return 'statusCode' in error && error.statusCode === 404
17
+ }
18
+ </script>
19
+
20
+ <template>
21
+ <NuxtErrorBoundary ref="boundary">
22
+ <slot />
23
+ <template #error="{ error, clearError }">
24
+ <DocsErrorState :kind="kind === 'page' && isMissing(error) ? 'notFound' : kind" @retry="clearError" />
25
+ </template>
26
+ </NuxtErrorBoundary>
27
+ </template>
@@ -0,0 +1,20 @@
1
+ <script setup lang="ts">
2
+ defineProps<{ missing?: boolean }>()
3
+ </script>
4
+
5
+ <template>
6
+ <svg viewBox="0 0 400 240" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false">
7
+ <path d="M40 187H360" stroke="var(--selaras-resolved-border-default)" stroke-width="2" stroke-linecap="round" />
8
+ <rect x="113" y="41" width="148" height="159" rx="12" fill="var(--selaras-resolved-surface-elevated)" stroke="var(--selaras-resolved-border-default)" stroke-width="2" />
9
+ <path d="M144 24H231L277 70V170C277 177 272 182 265 182H144C137 182 132 177 132 170V36C132 29 137 24 144 24Z" fill="var(--selaras-resolved-surface-default)" stroke="var(--selaras-resolved-color-primary-fill)" stroke-width="2.5" stroke-linejoin="round" />
10
+ <path d="M231 25V58C231 65 236 70 243 70H276" stroke="var(--selaras-resolved-color-primary-fill)" stroke-width="2.5" stroke-linejoin="round" />
11
+ <path d="M154 61H193M154 84H213" stroke="var(--selaras-resolved-border-default)" stroke-width="5" stroke-linecap="round" />
12
+ <rect x="154" y="108" width="57" height="48" rx="6" fill="var(--selaras-resolved-color-primary-subtle)" />
13
+ <path v-if="missing" d="M225 113H254V154H225V113Z" stroke="var(--selaras-resolved-color-primary-fill)" stroke-width="2" stroke-dasharray="4 5" stroke-linejoin="round" />
14
+ <path v-else d="M233 116V135M233 146V147" stroke="var(--selaras-resolved-color-primary-fill)" stroke-width="4" stroke-linecap="round" />
15
+ <path d="M56 153C56 122 84 119 105 132M290 109C318 103 342 122 333 146" stroke="var(--selaras-resolved-border-default)" stroke-width="2" stroke-dasharray="4 7" stroke-linecap="round" />
16
+ <circle cx="55" cy="159" r="6" fill="var(--selaras-resolved-color-primary-fill)" />
17
+ <rect x="311" y="144" width="44" height="37" rx="6" fill="var(--selaras-resolved-color-primary-subtle)" stroke="var(--selaras-resolved-color-primary-fill)" stroke-width="2" />
18
+ <path d="M323 163H343M333 153V173" stroke="var(--selaras-resolved-color-primary-fill)" stroke-width="2" stroke-linecap="round" />
19
+ </svg>
20
+ </template>
@@ -0,0 +1,81 @@
1
+ <script setup lang="ts">
2
+ import type { NuxtError } from '#app'
3
+
4
+ const props = defineProps<{ error: NuxtError }>()
5
+ const config = useRuntimeConfig()
6
+ const appConfig = useAppConfig()
7
+ const siteName = computed(() => appConfig.selarasDocs?.site?.name ?? 'Documentation')
8
+ const missing = computed(() => props.error.statusCode === 404)
9
+ const pending = ref(false)
10
+
11
+ useSeoMeta({
12
+ title: () => `${missing.value ? 'Page not found' : 'Something went wrong'} — ${siteName.value}`,
13
+ robots: 'noindex',
14
+ })
15
+
16
+ function reload() {
17
+ pending.value = true
18
+ window.location.reload()
19
+ }
20
+
21
+ async function goHome(event: MouseEvent) {
22
+ event.preventDefault()
23
+ const path = appConfig.selarasDocs?.errors?.homeTo ?? '/'
24
+ try {
25
+ await clearError({ redirect: path })
26
+ }
27
+ catch {
28
+ window.location.assign(`${config.app.baseURL.replace(/\/$/, '')}/${path.replace(/^\//, '')}`)
29
+ }
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div class="selaras-docs-root selaras-docs-error-page">
35
+ <header class="selaras-docs-error-header">
36
+ <a :href="config.app.baseURL" class="selaras-docs-error-brand" :aria-label="`${siteName} home`">
37
+ <DocsLogo />
38
+ <span>{{ siteName }}</span>
39
+ </a>
40
+ </header>
41
+ <main class="selaras-docs-error-main">
42
+ <DocsErrorState :kind="missing ? 'notFound' : 'fatal'" :pending="pending" @retry="reload" @home="goHome" />
43
+ </main>
44
+ </div>
45
+ </template>
46
+
47
+ <style scoped>
48
+ .selaras-docs-error-page {
49
+ min-height: 100svh;
50
+ color: var(--selaras-resolved-text-default);
51
+ }
52
+
53
+ .selaras-docs-error-header {
54
+ width: min(100%, 90rem);
55
+ margin: 0 auto;
56
+ padding: 1.5rem;
57
+ }
58
+
59
+ .selaras-docs-error-brand {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 0.75rem;
63
+ color: inherit;
64
+ font-size: 1.125rem;
65
+ font-weight: 650;
66
+ text-decoration: none;
67
+ }
68
+
69
+ .selaras-docs-error-brand:focus-visible {
70
+ border-radius: var(--selaras-resolved-radius-sm);
71
+ outline: 2px solid var(--selaras-resolved-color-primary-focus);
72
+ outline-offset: 4px;
73
+ }
74
+
75
+ .selaras-docs-error-main {
76
+ display: grid;
77
+ align-items: center;
78
+ min-height: calc(100svh - 6rem);
79
+ padding: 1.5rem 1.5rem 5rem;
80
+ }
81
+ </style>
@@ -0,0 +1,161 @@
1
+ <script setup lang="ts">
2
+ import type { SelarasDocsErrorKind } from '../../types'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ kind?: SelarasDocsErrorKind
6
+ pending?: boolean
7
+ showHome?: boolean
8
+ }>(), { kind: 'page', pending: false, showHome: undefined })
9
+
10
+ const emit = defineEmits<{ retry: [], home: [event: MouseEvent] }>()
11
+ const appConfig = useAppConfig()
12
+ const config = useRuntimeConfig()
13
+ const homeHref = computed(() => {
14
+ const path = appConfig.selarasDocs?.errors?.homeTo ?? '/'
15
+ const base = config.app.baseURL.replace(/\/$/, '')
16
+ return `${base}/${path.replace(/^\//, '')}`
17
+ })
18
+ const headingId = useId()
19
+ const heading = useTemplateRef<HTMLElement>('heading')
20
+ onMounted(() => {
21
+ if (props.kind !== 'example')
22
+ heading.value?.focus({ preventScroll: true })
23
+ })
24
+ const copy = computed(() => {
25
+ const defaults = {
26
+ notFound: { title: 'Page not found', description: 'The link may have changed, or this page may no longer exist. You can start again from the homepage.' },
27
+ fatal: { title: 'Something went wrong', description: 'We couldn’t load this page. Reload it to try again, or return to the homepage.' },
28
+ page: { title: 'This page couldn’t load', description: 'Please try again. You can still use the navigation to explore other pages.' },
29
+ example: { title: 'This example couldn’t load', description: 'Try loading the example again. The rest of the documentation is still available.' },
30
+ }
31
+ return { ...defaults[props.kind], ...appConfig.selarasDocs?.errors?.[props.kind] }
32
+ })
33
+ const labels = computed(() => appConfig.selarasDocs?.errors ?? {})
34
+ const showHome = computed(() => props.showHome ?? props.kind !== 'example')
35
+ </script>
36
+
37
+ <template>
38
+ <section
39
+ class="selaras-docs-error-state not-prose"
40
+ :data-error-kind="kind"
41
+ :aria-labelledby="headingId"
42
+ :role="kind === 'example' ? 'alert' : undefined"
43
+ >
44
+ <div class="selaras-docs-error-artwork">
45
+ <slot name="illustration">
46
+ <DocsErrorIllustration :missing="kind === 'notFound'" />
47
+ </slot>
48
+ </div>
49
+ <div class="selaras-docs-error-copy">
50
+ <component :is="kind === 'example' ? 'h3' : 'h1'" :id="headingId" ref="heading" class="selaras-docs-error-title" :tabindex="kind === 'example' ? undefined : -1">
51
+ {{ copy.title }}
52
+ </component>
53
+ <p class="selaras-docs-error-description">
54
+ {{ copy.description }}
55
+ </p>
56
+ <div class="selaras-docs-error-actions">
57
+ <SButton v-if="kind !== 'notFound'" :loading="pending" :disabled="pending" @click="emit('retry')">
58
+ {{ pending ? labels.retryingLabel ?? 'Trying again…' : kind === 'fatal' ? labels.reloadLabel ?? 'Reload page' : labels.retryLabel ?? 'Try again' }}
59
+ </SButton>
60
+ <SButton v-if="showHome" as="a" :href="homeHref" :variant="kind === 'notFound' ? 'solid' : 'outline'" :color="kind === 'notFound' ? 'primary' : 'neutral'" @click="emit('home', $event)">
61
+ {{ labels.homeLabel ?? 'Go to homepage' }}
62
+ </SButton>
63
+ </div>
64
+ </div>
65
+ </section>
66
+ </template>
67
+
68
+ <style scoped>
69
+ .selaras-docs-error-state {
70
+ width: min(100%, 42rem);
71
+ margin: 3rem auto;
72
+ color: var(--selaras-resolved-text-default);
73
+ text-align: center;
74
+ }
75
+
76
+ .selaras-docs-error-artwork {
77
+ width: min(100%, 22rem);
78
+ margin: 0 auto 1.5rem;
79
+ }
80
+
81
+ .selaras-docs-error-artwork :deep(svg) {
82
+ display: block;
83
+ width: 100%;
84
+ height: auto;
85
+ }
86
+
87
+ .selaras-docs-error-title {
88
+ margin: 0;
89
+ font-size: clamp(1.75rem, 1.4rem + 1vw, 2.5rem);
90
+ font-weight: 650;
91
+ line-height: 1.15;
92
+ letter-spacing: -0.025em;
93
+ text-wrap: balance;
94
+ }
95
+
96
+ .selaras-docs-error-title:focus {
97
+ outline: none;
98
+ }
99
+
100
+ .selaras-docs-error-description {
101
+ max-width: 44ch;
102
+ margin: 1rem auto 0;
103
+ color: var(--selaras-resolved-text-muted);
104
+ font-size: 1rem;
105
+ line-height: 1.7;
106
+ }
107
+
108
+ .selaras-docs-error-actions {
109
+ display: flex;
110
+ flex-wrap: wrap;
111
+ justify-content: center;
112
+ gap: 0.75rem;
113
+ margin-top: 1.75rem;
114
+ }
115
+
116
+ .selaras-docs-error-state[data-error-kind="example"] {
117
+ display: flex;
118
+ align-items: center;
119
+ gap: 1.25rem;
120
+ width: 100%;
121
+ margin: 0;
122
+ padding: 1.5rem;
123
+ border: 1px solid var(--selaras-resolved-border-default);
124
+ border-radius: var(--selaras-resolved-radius-lg);
125
+ background: var(--selaras-resolved-surface-elevated);
126
+ text-align: start;
127
+ }
128
+
129
+ [data-error-kind="example"] .selaras-docs-error-artwork {
130
+ flex: none;
131
+ width: 6rem;
132
+ margin: 0;
133
+ }
134
+
135
+ [data-error-kind="example"] .selaras-docs-error-title {
136
+ font-size: 1rem;
137
+ line-height: 1.4;
138
+ letter-spacing: normal;
139
+ }
140
+
141
+ [data-error-kind="example"] .selaras-docs-error-description {
142
+ margin: 0.375rem 0 0;
143
+ font-size: 0.875rem;
144
+ }
145
+
146
+ [data-error-kind="example"] .selaras-docs-error-actions {
147
+ justify-content: flex-start;
148
+ margin-top: 1rem;
149
+ }
150
+
151
+ @media (max-width: 30rem) {
152
+ .selaras-docs-error-state[data-error-kind="example"] {
153
+ align-items: flex-start;
154
+ flex-direction: column;
155
+ }
156
+
157
+ [data-error-kind="example"] .selaras-docs-error-artwork {
158
+ display: none;
159
+ }
160
+ }
161
+ </style>
@@ -18,6 +18,11 @@ const footer = computed(() => docsConfig.value.footer)
18
18
  :aria-label="link.ariaLabel ?? link.label"
19
19
  >
20
20
  <SIcon v-if="link.icon" :name="link.icon" />
21
+ <svg v-else-if="link.to === 'https://selarasui.my.id'" class="selaras-docs-footer-attribution-logo" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true" focusable="false">
22
+ <path d="M28 80 H68 C94 80 118 36 144 36 H228 V62 H144 C118 62 94 106 68 106 H28 Z" />
23
+ <path d="M28 134 H68 C94 134 118 90 144 90 H228 V116 H144 C118 116 94 160 68 160 H28 Z" />
24
+ <path d="M28 188 H68 C94 188 118 144 144 144 H228 V170 H144 C118 170 94 214 68 214 H28 Z" />
25
+ </svg>
21
26
  <span v-if="link.label">{{ link.label }}</span>
22
27
  </NuxtLink>
23
28
  </nav>
@@ -0,0 +1,25 @@
1
+ <script setup lang="ts">
2
+ import { getDocsRepositoryLinks } from '../utils/repository-links'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ path: string
6
+ title?: string
7
+ editLink?: boolean
8
+ issueLink?: boolean
9
+ }>(), { editLink: true, issueLink: true })
10
+
11
+ const appConfig = useAppConfig()
12
+ const links = computed(() => getDocsRepositoryLinks(appConfig.selarasDocs?.repository, props.path, props.title))
13
+ const hasEditLink = computed(() => props.editLink && !!links.value.edit)
14
+ const hasIssueLink = computed(() => props.issueLink && !!links.value.issue)
15
+ </script>
16
+
17
+ <template>
18
+ <nav v-if="hasEditLink || hasIssueLink" class="selaras-docs-page-actions" aria-label="Page actions">
19
+ <DocsEditLink v-if="hasEditLink" :path="path" />
20
+ <a v-if="hasIssueLink" :href="links.issue" target="_blank" rel="noreferrer" class="selaras-docs-issue-link">
21
+ <SIcon name="hugeicons:bug-01" aria-hidden="true" />
22
+ Report an issue
23
+ </a>
24
+ </nav>
25
+ </template>
@@ -17,5 +17,6 @@ const docsConfig = computed(() => appConfig.selarasDocs ?? {})
17
17
  :ui="{ root: 'top-16 h-[calc(100vh-4rem)]' }"
18
18
  >
19
19
  <SContentToc :links="links" :title="docsConfig.toc?.title" />
20
+ <slot />
20
21
  </SPageAside>
21
22
  </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <slot />
3
+ </template>
@@ -18,7 +18,9 @@ const resolvedComponent = computed(() => {
18
18
 
19
19
  <template>
20
20
  <section v-if="resolvedComponent" :data-example="name">
21
- <component :is="resolvedComponent" />
21
+ <DocsErrorBoundary kind="example" :reset-key="name">
22
+ <component :is="resolvedComponent" />
23
+ </DocsErrorBoundary>
22
24
  </section>
23
25
  <p v-else role="alert">
24
26
  Example "{{ name }}" not found
package/app/error.vue ADDED
@@ -0,0 +1,11 @@
1
+ <script setup lang="ts">
2
+ import type { NuxtError } from '#app'
3
+
4
+ defineProps<{ error: NuxtError }>()
5
+ </script>
6
+
7
+ <template>
8
+ <SApp>
9
+ <DocsErrorPage :error="error" />
10
+ </SApp>
11
+ </template>
@@ -10,14 +10,30 @@ import ProsePre from '@sewadah/selaras/components/ProsePre.vue'
10
10
 
11
11
  const route = useRoute()
12
12
  const appConfig = useAppConfig()
13
- const { data: page } = await useAsyncData(`selaras-docs:${route.path}`, () => queryCollection('docs').path(route.path).first())
13
+ const { data: page, error, status, refresh } = await useAsyncData(`selaras-docs:${route.path}`, () => queryCollection('docs').path(route.path).first())
14
14
 
15
- if (!page.value) {
15
+ if (!page.value && !error.value) {
16
16
  throw createError({ statusCode: 404, statusMessage: 'Documentation page not found', fatal: true })
17
17
  }
18
+ if (import.meta.server && error.value)
19
+ setResponseStatus(500)
20
+
21
+ async function retryPage() {
22
+ await refresh()
23
+ if (import.meta.client && error.value) {
24
+ window.location.reload()
25
+ return
26
+ }
27
+ if (!page.value && !error.value)
28
+ showError({ statusCode: 404, statusMessage: 'Documentation page not found' })
29
+ }
18
30
 
19
31
  definePageMeta({ layout: 'selaras-docs' })
20
- useSeoMeta({ title: page.value.title, description: page.value.description ?? appConfig.selarasDocs?.site?.description })
32
+ useSeoMeta({
33
+ title: () => page.value?.title ?? appConfig.selarasDocs?.errors?.page?.title ?? 'Documentation unavailable',
34
+ description: () => page.value?.description ?? appConfig.selarasDocs?.site?.description,
35
+ robots: () => error.value ? 'noindex' : undefined,
36
+ })
21
37
  useHead(() => ({
22
38
  ...(appConfig.selarasDocs?.site?.titleTemplate && { titleTemplate: appConfig.selarasDocs.site.titleTemplate }),
23
39
  ...(appConfig.selarasDocs?.site?.url && {
@@ -36,18 +52,30 @@ const proseComponents = {
36
52
  }
37
53
 
38
54
  const tocLinks = computed(() => (page.value?.body?.toc?.links ?? []) as unknown as TocLink[])
55
+ const hasToc = computed(() => page.value?.toc !== false && page.value?.aside !== false && appConfig.selarasDocs?.toc?.enabled !== false && tocLinks.value.length > 0)
39
56
  const surround = useDocsSurround()
40
57
  </script>
41
58
 
42
59
  <template>
43
- <SContainer size="full" class="selaras-docs-page" :class="{ 'selaras-docs-page--fluid': appConfig.selarasDocs?.main?.fluid }">
44
- <div class="selaras-docs-page-grid">
60
+ <DocsErrorState v-if="error || !page" kind="page" :pending="status === 'pending'" @retry="retryPage" />
61
+ <SContainer v-else size="full" class="selaras-docs-page" :class="{ 'selaras-docs-page--fluid': appConfig.selarasDocs?.main?.fluid }">
62
+ <div class="selaras-docs-page-grid" :class="{ 'selaras-docs-page-grid--wide': !hasToc }">
45
63
  <article class="selaras-docs-article">
46
64
  <p v-if="page!.description" class="selaras-docs-description">
47
65
  {{ page!.description }}
48
66
  </p>
49
- <ContentRenderer :value="page!" :components="proseComponents" class="selaras-docs-content selaras-prose" />
50
- <DocsEditLink v-if="page!.editLink !== false" :path="route.path" />
67
+ <ContentRenderer :value="page!" :components="proseComponents" class="selaras-docs-content selaras-prose">
68
+ <template #empty>
69
+ <DocsEmptyState />
70
+ </template>
71
+ </ContentRenderer>
72
+ <DocsPageActions
73
+ :path="route.path"
74
+ :title="page!.title"
75
+ :edit-link="page!.editLink !== false"
76
+ :issue-link="page!.issueLink !== false"
77
+ :class="{ 'selaras-docs-page-actions--mobile': hasToc }"
78
+ />
51
79
  <SContentSurround
52
80
  v-if="page!.prevNext !== false"
53
81
  :prev="surround.prev"
@@ -55,7 +83,9 @@ const surround = useDocsSurround()
55
83
  class="selaras-docs-surround"
56
84
  />
57
85
  </article>
58
- <DocsTableOfContents v-if="page!.toc !== false && page!.aside !== false" :links="tocLinks" />
86
+ <DocsTableOfContents v-if="hasToc" :links="tocLinks">
87
+ <DocsPageActions :path="route.path" :title="page!.title" :edit-link="page!.editLink !== false" :issue-link="page!.issueLink !== false" />
88
+ </DocsTableOfContents>
59
89
  </div>
60
90
  </SContainer>
61
91
  </template>
@@ -0,0 +1,29 @@
1
+ import type { SelarasDocsAppConfig } from '../../types'
2
+
3
+ export function getDocsRepositoryLinks(repository: SelarasDocsAppConfig['repository'], path: string, title?: string) {
4
+ const links: { edit?: string, issue?: string } = {}
5
+ const base = repository?.url?.replace(/\/$/, '')
6
+ const github = base?.startsWith('https://github.com/')
7
+ const gitlab = base?.startsWith('https://gitlab.com/')
8
+
9
+ if (base && (github || gitlab) && repository?.editLinks !== false) {
10
+ const relativePath = path === '/' ? 'index.md' : `${path.replace(/^\//, '')}.md`
11
+ const contentPath = [repository?.contentDirectory ?? 'content', relativePath].join('/')
12
+ const branch = repository?.branch ?? 'main'
13
+ links.edit = `${base}/${gitlab ? '-/edit' : 'edit'}/${branch}/${contentPath}`
14
+ }
15
+
16
+ if (repository?.issueLinks !== false) {
17
+ if (repository?.issueUrl) {
18
+ links.issue = repository.issueUrl
19
+ }
20
+ else if (base && (github || gitlab)) {
21
+ const url = new URL(`${base}/${gitlab ? '-/issues/new' : 'issues/new'}`)
22
+ url.searchParams.set(gitlab ? 'issue[title]' : 'title', `Documentation: ${title ?? path}`)
23
+ url.searchParams.set(gitlab ? 'issue[description]' : 'body', `Documentation page: ${path}\n\n`)
24
+ links.issue = url.href
25
+ }
26
+ }
27
+
28
+ return links
29
+ }
package/content.config.ts CHANGED
@@ -42,6 +42,7 @@ export default defineContentConfig({
42
42
  toc: z.boolean().default(true),
43
43
  aside: z.boolean().default(true),
44
44
  editLink: z.boolean().default(true),
45
+ issueLink: z.boolean().default(true),
45
46
  prevNext: z.boolean().default(true),
46
47
  }),
47
48
  }),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sewadah/selaras-docs",
3
3
  "type": "module",
4
- "version": "1.2.3",
4
+ "version": "1.3.0",
5
5
  "description": "Nuxt Content documentation layer for Selaras",
6
6
  "repository": {
7
7
  "type": "git",
@@ -33,7 +33,7 @@
33
33
  "dependencies": {
34
34
  "@nuxt/content": "^3.16.0",
35
35
  "@nuxt/kit": "^4.5.2",
36
- "@sewadah/selaras": "^0.8.0",
36
+ "@sewadah/selaras": "^0.9.0",
37
37
  "tailwindcss": "^4.3.0"
38
38
  }
39
39
  }
package/types.d.ts CHANGED
@@ -37,7 +37,28 @@ export interface SelarasDocsLogo {
37
37
  tint?: boolean
38
38
  }
39
39
 
40
+ export type SelarasDocsErrorKind = 'notFound' | 'fatal' | 'page' | 'example'
41
+
42
+ export interface SelarasDocsErrorCopy {
43
+ title?: string
44
+ description?: string
45
+ }
46
+
40
47
  export interface SelarasDocsAppConfig {
48
+ /** Copy shown for an existing Markdown page with no body content. */
49
+ empty?: {
50
+ title?: string
51
+ description?: string
52
+ }
53
+ /** Recovery messages and the destination of error-screen home links. */
54
+ errors?: Partial<Record<SelarasDocsErrorKind, SelarasDocsErrorCopy>> & {
55
+ /** An internal route, resolved under the application's base URL. @default '/' */
56
+ homeTo?: string
57
+ homeLabel?: string
58
+ retryLabel?: string
59
+ reloadLabel?: string
60
+ retryingLabel?: string
61
+ }
41
62
  site?: {
42
63
  name?: string
43
64
  description?: string
@@ -54,13 +75,17 @@ export interface SelarasDocsAppConfig {
54
75
  branch?: string
55
76
  contentDirectory?: string
56
77
  editLinks?: boolean
78
+ /** Show a page-context issue link. @default true */
79
+ issueLinks?: boolean
80
+ /** Override the new-issue destination, e.g. for a separate issue tracker. */
81
+ issueUrl?: string
57
82
  }
58
83
  header?: {
59
84
  title?: string
60
85
  showTitle?: boolean
61
86
  search?: boolean
62
87
  colorMode?: boolean
63
- /** Show the consuming site's visual preset picker. @default false */
88
+ /** Show the consuming site's DocsThemePicker component; the layer default is empty. @default false */
64
89
  themePicker?: boolean
65
90
  /** Let the header span the viewport instead of the docs shell width. */
66
91
  fluid?: boolean