diff --git a/docs/tutorialkit.dev/src/content/docs/guides/deployment.mdx b/docs/tutorialkit.dev/src/content/docs/guides/deployment.mdx index 59b7a3e2f..dd34ea417 100644 --- a/docs/tutorialkit.dev/src/content/docs/guides/deployment.mdx +++ b/docs/tutorialkit.dev/src/content/docs/guides/deployment.mdx @@ -34,6 +34,17 @@ This will generate a `dist` directory containing the static files that make up y You can learn more about the build process in the [Astro documentation](https://docs.astro.build/en/reference/cli-reference/#astro-build). +## Environment variables + +The [`site`](https://docs.astro.build/reference/configuration-reference/#site) configuration should point to your website's absolute URL. +This will allow to compute absolute URLs for SEO metadata. + +Example: +```js +// astro.config.mjs +site:"https://tutorialkit.dev" +``` + ## Headers configuration The preview and terminal features in TutorialKit rely on WebContainers technology. To ensure that this technology works correctly, you need to configure the headers of your web server to ensure the site is cross-origin isolated (you can read more about this at [webcontainers.io](https://webcontainers.io/guides/configuring-headers)). diff --git a/packages/astro/src/default/components/Logo.astro b/packages/astro/src/default/components/Logo.astro index 88bd9c66d..7d67dbcd7 100644 --- a/packages/astro/src/default/components/Logo.astro +++ b/packages/astro/src/default/components/Logo.astro @@ -1,30 +1,11 @@ --- -import fs from 'node:fs'; -import path from 'node:path'; -import { joinPaths } from '../utils/url'; - -const LOGO_EXTENSIONS = ['svg', 'png', 'jpeg', 'jpg']; +import { LOGO_EXTENSIONS } from '../utils/constants'; +import { readLogoFile } from '../utils/logo'; interface Props { logoLink: string; } -function readLogoFile(logoPrefix: string) { - let logo; - - for (const logoExt of LOGO_EXTENSIONS) { - const logoFilename = `${logoPrefix}.${logoExt}`; - const exists = fs.existsSync(path.join('public', logoFilename)); - - if (exists) { - logo = joinPaths(import.meta.env.BASE_URL, logoFilename); - break; - } - } - - return logo; -} - const { logoLink } = Astro.props; const logo = readLogoFile('logo'); diff --git a/packages/astro/src/default/components/MetaTags.astro b/packages/astro/src/default/components/MetaTags.astro new file mode 100644 index 000000000..d91c4f2a0 --- /dev/null +++ b/packages/astro/src/default/components/MetaTags.astro @@ -0,0 +1,31 @@ +--- +import type { MetaTagsConfig } from '@tutorialkit/types'; +import { readLogoFile } from '../utils/logo'; +import { readPublicAsset } from '../utils/publicAsset'; + +interface Props { + meta?: MetaTagsConfig; +} +const { meta = {} } = Astro.props; +let imageUrl; +if (meta.image) { + imageUrl = readPublicAsset(meta.image, true); + if (!imageUrl) { + console.warn(`Image ${meta.image} not found in "/public" folder`); + } +} +imageUrl ??= readLogoFile('logo', true); +--- + + + + +{meta.description ? : null} +{/* open graph */} +{meta.title ? : null} +{meta.description ? : null} +{imageUrl ? : null} +{/* twitter */} +{meta.title ? : null} +{meta.description ? : null} +{imageUrl ? : null} diff --git a/packages/astro/src/default/layouts/Layout.astro b/packages/astro/src/default/layouts/Layout.astro index f72a0202a..ae98d9826 100644 --- a/packages/astro/src/default/layouts/Layout.astro +++ b/packages/astro/src/default/layouts/Layout.astro @@ -1,24 +1,23 @@ --- import { ViewTransitions } from 'astro:transitions'; -import { joinPaths } from '../utils/url'; +import type { MetaTagsConfig } from '@tutorialkit/types'; +import MetaTags from '../components/MetaTags.astro'; +import { readPublicAsset } from '../utils/publicAsset'; interface Props { title: string; + meta?: MetaTagsConfig; } - -const { title } = Astro.props; -const baseURL = import.meta.env.BASE_URL; +const { title, meta } = Astro.props; +const faviconUrl = readPublicAsset('favicon.svg'); --- - - - - {title} - + {faviconUrl ? : null} + diff --git a/packages/astro/src/default/pages/[...slug].astro b/packages/astro/src/default/pages/[...slug].astro index 5738adb89..b03f55d74 100644 --- a/packages/astro/src/default/pages/[...slug].astro +++ b/packages/astro/src/default/pages/[...slug].astro @@ -15,9 +15,14 @@ export async function getStaticPaths() { type Props = InferGetStaticPropsType; const { lesson, logoLink, navList, title } = Astro.props as Props; +const meta = lesson.data?.meta ?? {}; + +// use lesson's default title and a default description for SEO metadata +meta.title ??= title; +meta.description ??= 'A TutorialKit interactive lesson'; --- - +
diff --git a/packages/astro/src/default/utils/constants.ts b/packages/astro/src/default/utils/constants.ts index 120733862..38ca4cf97 100644 --- a/packages/astro/src/default/utils/constants.ts +++ b/packages/astro/src/default/utils/constants.ts @@ -2,3 +2,5 @@ export const RESIZABLE_PANELS = { Main: 'main', } as const; export const IGNORED_FILES = ['**/.DS_Store', '**/*.swp']; + +export const LOGO_EXTENSIONS = ['svg', 'png', 'jpeg', 'jpg']; diff --git a/packages/astro/src/default/utils/content.ts b/packages/astro/src/default/utils/content.ts index 328209d90..46e19fe95 100644 --- a/packages/astro/src/default/utils/content.ts +++ b/packages/astro/src/default/utils/content.ts @@ -245,6 +245,7 @@ export async function getTutorial(): Promise { 'editor', 'focus', 'i18n', + 'meta', 'editPageLink', 'openInStackBlitz', 'filesystem', diff --git a/packages/astro/src/default/utils/logo.ts b/packages/astro/src/default/utils/logo.ts new file mode 100644 index 000000000..3036f6324 --- /dev/null +++ b/packages/astro/src/default/utils/logo.ts @@ -0,0 +1,17 @@ +import { LOGO_EXTENSIONS } from './constants'; +import { readPublicAsset } from './publicAsset'; + +export function readLogoFile(logoPrefix: string = 'logo', absolute?: boolean) { + let logo; + + for (const logoExt of LOGO_EXTENSIONS) { + const logoFilename = `${logoPrefix}.${logoExt}`; + logo = readPublicAsset(logoFilename, absolute); + + if (logo) { + break; + } + } + + return logo; +} diff --git a/packages/astro/src/default/utils/publicAsset.ts b/packages/astro/src/default/utils/publicAsset.ts new file mode 100644 index 000000000..7dfca8eaf --- /dev/null +++ b/packages/astro/src/default/utils/publicAsset.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { joinPaths } from './url'; + +export function readPublicAsset(filename: string, absolute?: boolean) { + let asset; + const exists = fs.existsSync(path.join('public', filename)); + + if (!exists) { + return; + } + + asset = joinPaths(import.meta.env.BASE_URL, filename); + + if (absolute) { + const site = import.meta.env.SITE; + + if (!site) { + // the SITE env variable inherits the value from Astro.site configuration + console.warn('Trying to compute an absolute file URL but Astro.site is not set.'); + } else { + asset = joinPaths(site, asset); + } + } + + return asset; +} diff --git a/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap b/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap index 62b060f9d..49403364c 100644 --- a/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap +++ b/packages/cli/tests/__snapshots__/create-tutorial.test.ts.snap @@ -206,6 +206,7 @@ exports[`create and eject a project 1`] = ` "src/components/LoginButton.tsx", "src/components/Logo.astro", "src/components/MainContainer.astro", + "src/components/MetaTags.astro", "src/components/MobileContentToggle.astro", "src/components/NavCard.astro", "src/components/NavWrapper.tsx", @@ -307,7 +308,9 @@ exports[`create and eject a project 1`] = ` "src/utils/content/files-ref.ts", "src/utils/content/squash.ts", "src/utils/logger.ts", + "src/utils/logo.ts", "src/utils/nav.ts", + "src/utils/publicAsset.ts", "src/utils/routes.ts", "src/utils/url.ts", "src/utils/workspace.ts", diff --git a/packages/template/src/content/tutorial/1-basics/1-introduction/1-welcome/content.md b/packages/template/src/content/tutorial/1-basics/1-introduction/1-welcome/content.md index 9c35459ba..cf48965a5 100644 --- a/packages/template/src/content/tutorial/1-basics/1-introduction/1-welcome/content.md +++ b/packages/template/src/content/tutorial/1-basics/1-introduction/1-welcome/content.md @@ -11,6 +11,9 @@ prepareCommands: - ['node -e setTimeout(()=>{process.exit(1)},5000)', 'This is going to fail'] terminal: panels: ['terminal', 'output'] +meta: + description: "This is lesson 1" + image: "/logo.svg" --- # Kitchen Sink [Heading 1] diff --git a/packages/types/src/entities/index.ts b/packages/types/src/entities/index.ts index dd224b2b2..81c828701 100644 --- a/packages/types/src/entities/index.ts +++ b/packages/types/src/entities/index.ts @@ -1,5 +1,6 @@ import type { I18nSchema } from '../schemas/i18n.js'; import type { ChapterSchema, LessonSchema, PartSchema } from '../schemas/index.js'; +import type { MetaTagsSchema } from '../schemas/metatags.js'; export type * from './nav.js'; @@ -57,6 +58,8 @@ export interface Lesson { export type I18n = Required>; +export type MetaTagsConfig = MetaTagsSchema; + export interface Tutorial { logoLink?: string; firstPartId?: string; diff --git a/packages/types/src/schemas/common.ts b/packages/types/src/schemas/common.ts index dcb4f9c5b..faa8be849 100644 --- a/packages/types/src/schemas/common.ts +++ b/packages/types/src/schemas/common.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { i18nSchema } from './i18n.js'; +import { metaTagsSchema } from './metatags.js'; export const commandSchema = z.union([ // a single string, the command to run @@ -207,6 +208,8 @@ export type TerminalSchema = z.infer; export type EditorSchema = z.infer; export const webcontainerSchema = commandsSchema.extend({ + meta: metaTagsSchema.optional(), + previews: previewSchema .optional() .describe( diff --git a/packages/types/src/schemas/metatags.ts b/packages/types/src/schemas/metatags.ts new file mode 100644 index 000000000..3a3e3b9d7 --- /dev/null +++ b/packages/types/src/schemas/metatags.ts @@ -0,0 +1,16 @@ +import { z } from 'zod'; + +export const metaTagsSchema = z.object({ + image: z + .string() + .optional() + /** + * Ideally we would want to use `image` from: + * https://docs.astro.build/en/guides/images/#images-in-content-collections . + */ + .describe('A relative path to an image that lives in the public folder to show on social previews.'), + description: z.string().optional().describe('A description for metadata'), + title: z.string().optional().describe('A title to use specifically for metadata'), +}); + +export type MetaTagsSchema = z.infer;