Directus CMS + Docker

GitHub

DockerHub

https://hub.docker.com/r/directus/directus

Dockerガイド

データベースにPostgreSQLを選択。

docker-compose.yml

version: "3.7"
services:
##### redis-server
  redis:
    container_name: redis
    image: redis:alpine
    volumes:
        - ./redis:/etc/redis
    # Error Failed to write PID file, Permission denied ---> pidfile /tmp/redis_6379.pid in redis.conf
    # WARNING overcommit_memory is set to 0! Background save may fail under low memory condition. 
    # To fix this issue add 'vm.overcommit_memory = 1' to /etc/sysctl.conf and then reboot or 
    # run the command 'sysctl vm.overcommit_memory=1' for this to take effect.---> on host machine!
    # need to download default config file:redis.conf from https://redis.io/topics/config
    # In redis.conf : bind 172.18.0.2, protected-mode no, maxmemory 250mb, maxmemory-policy allkeys-lru
    command: ["redis-server", "/etc/redis/redis.conf"]
    restart: always
    networks: 
      proxy-tier:
        ipv4_address: 172.18.0.2
              
  postgres:
    image: postgres:alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${DB_DATABASE}
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - ./postgres-data:/var/lib/postgresql/data
    networks: 
      proxy-tier:
        ipv4_address: 172.22.0.3

  directus:
    container_name: directus
    image: directus/directus:10.9.0
    ports:
      - 8055:8055
    volumes:
      - ./uploads:/directus/uploads
      # If you want to load extensions from the host
      # - ./extensions:/directus/extensions
    depends_on:
      - redis
      - postgres
    environment:
      KEY: ${KEY}
      SECRET: ${SECRET}

      DB_CLIENT: ${DB_CLIENT}
      DB_HOST: ${DB_HOST}
      DB_PORT: ${DB_PORT}
      DB_DATABASE: ${DB_DATABASE}
      DB_USER: ${DB_USER}
      DB_PASSWORD: ${DB_PASSWORD}

      CACHE_ENABLED: "true"
      CACHE_STORE: "redis"
      REDIS: "redis://redis:6379"
      
      # Should create aclfile /etc/redis/users.acl
      #REDIS_USERNAME: ${REDIS_USERNAME}
      #REDIS_PASSWORD: ${REDIS_PASSWORD}

      ADMIN_EMAIL: ${ADMIN_EMAIL}
      ADMIN_PASSWORD: ${ADMIN_PASSWORD}

      CORS_ENABLED: true
      CORS_ORIGIN: true
      
    networks: 
      proxy-tier:
        ipv4_address: 172.18.0.4

      # Make sure to set this in production
      # (see https://docs.directus.io/self-hosted/config-options#general)
      # PUBLIC_URL: "https://directus.example.com"

    # Environment variables can also be defined in a file (for example `.env`):
    # env_file:
    #	  - .env

# For IPv4 only
# docker network create --gateway 172.18.0.1 --subnet 172.18.0.0/24 containers-network

networks:
  proxy-tier:
    name: containers-network
    external: true
    

.env

HOST="0.0.0.0"
PORT=8055

KEY="xxxx-xxxx-xxxx-xxxx-xxxx"
SECRET="xxxx-xxxx-xxxx-xxxx-xxxx"

DB_CLIENT="pg"
DB_HOST="postgres"
DB_PORT=5432
DB_DATABASE="directus"
DB_USER="directus"
DB_PASSWORD="xxxxxxxxxxxxxxxxxxxxxxxx"

ADMIN_EMAIL="[email protected]"
ADMIN_PASSWORD="xxxxxxxxxxxxxxxxxxxxxxxxx"

Database

Variable Description Default Value
DB_CLIENT Required. What database client to use. One of pg or postgres, mysql, oracledb, mssql, sqlite3, cockroachdb.
DB_HOST Database host. Required when using pg, mysql, oracledb, or mssql.
DB_PORT Database port. Required when using pg, mysql, oracledb, or mssql.
DB_DATABASE Database name. Required when using pg, mysql, oracledb, or mssql.
DB_USER Database user. Required when using pg, mysql, oracledb, or mssql.
DB_PASSWORD Database user’s password. Required when using pg, mysql, oracledb, or mssql.

Redis

Directus requires Redis for multi-container deployments. This ensures that things like caching, rate-limiting, and WebSockets work reliably across multiple containers of Directus.

Variable Description Default Value
REDIS Redis connection string, e.g., redis://user:[email protected]:6380/4. Using this will ignore the other Redis connection parameter environment variables
REDIS_HOST Hostname of the Redis instance, e.g., "127.0.0.1"
REDIS_PORT Port of the Redis instance, e.g., 6379
REDIS_USERNAME Username for your Redis instance, e.g., "default"
REDIS_PASSWORD Password for your Redis instance, e.g., "yourRedisPassword"

Universally Unique Identifiers (UUIDs)

uuidgenコマンド:Ubuntu22.04ではデフォルトでインストール済

$ uuidgen
e897956e-6a7b-4010-b7cc-6b3ce802916d

トラブルシュート

ログインエラー :データベースにMySQL(MariaDB)を選択した場合

directus:log

.......
.......
WARN: Some tables and columns do not match your database's default collation (utf8mb4_general_ci):
directus  | 		- Table "directus_webhooks": "utf8mb4_general_ci"
directus  | 		  - Column "headers": "utf8mb4_bin"
directus  | 		- Table "directus_files": "utf8mb4_general_ci"
directus  | 		  - Column "metadata": "utf8mb4_bin"
directus  | 		- Table "directus_fields": "utf8mb4_general_ci"
directus  | 		  - Column "options": "utf8mb4_bin"
directus  | 		  - Column "display_options": "utf8mb4_bin"
directus  | 		  - Column "translations": "utf8mb4_bin"
directus  | 		  - Column "conditions": "utf8mb4_bin"
directus  | 		  - Column "validation": "utf8mb4_bin"
.......
.....
directus  | [00:23:21] POST /auth/refresh 400 18ms
directus  | [00:23:21] GET /auth 200 21ms
directus  | [00:23:21] GET /server/info 200 26ms
directus  | [00:23:21] GET /translations?fields[]=language&fields[]=key&fields[]=value&filter[language][_eq]=en-US&limit=-1 403 19ms
directus  | [00:23:35] POST /auth/login 401 569ms
directus  | [00:25:56] POST /auth/login 401 534ms
directus  | [00:26:18] POST /auth/login 401 516ms
directus  | [00:29:22] GET /en/admin/login 404 18ms

MariaDBコンテナにコマンドオプションを追加。

docer-compose.yaml

  mariadb:
    container_name: mariadb
    restart: always
    image: mariadb:latest
    environment:
      MYSQL_USER: ${DB_USER}
      MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
      MYSQL_PASSWORD: ${DB_PASSWORD}
      MYSQL_DATABASE: ${DB_DATABASE}
    volumes:
      - ./db:/var/lib/mysql
    command:
            [
                "--character-set-server=utf8mb4",
                "--collation-server=utf8mb4_bin",
            ]

mariadb Command-Line Client - MariaDB Knowledge Base


CORSエラー

ページのリンク先へのジャンプがリジェクトされるエラー。

CORS

directusコンテナの環境変数でCORSを有効にすること。

Variable Description Default Value
CORS_ENABLED Whether or not to enable the CORS headers. false
CORS_ORIGIN Value for the Access-Control-Allow-Origin header. Use true to match the Origin header, or provide a domain or a CSV of domains for specific access false
CORS_METHODS Value for the Access-Control-Allow-Methods header. GET,POST,PATCH,DELETE
CORS_ALLOWED_HEADERS Value for the Access-Control-Allow-Headers header. Content-Type,Authorization
CORS_EXPOSED_HEADERS Value for the Access-Control-Expose-Headers header. Content-Range
CORS_CREDENTIALS Whether or not to send the Access-Control-Allow-Credentials header. true
CORS_MAX_AGE Value for the Access-Control-Max-Age header. 18000

ロケール変更時エラー

setLocale(locale === ‘en’ ? ‘ja’ : ‘en’); の箇所について以下の警告

‘WritableComputedRef’ 型と ‘string’ 型が重複していないため、この比較は意図したとおりに表示されない可能性があります

はい、その警告は、locale 変数が WritableComputedRef<string> 型であるために発生します。

問題:

WritableComputedRef<string> 型は、値が変更可能な計算プロパティを表します。つまり、locale 変数の値は、コード実行中に変更される可能性があります。

一方、string 型は、文字列リテラルを表します。つまり、'en''ja' などの文字列は、コード実行中に変更されません。

解決策:

この警告を解決するには、locale 変数の値を比較する前に、明示的に文字列に変換する必要があります。

解決策 1:

`JavaScriptconst changeLocale = () => {
// 現在のロケールを取得
const currentLocale = locale.value;

// 現在のロケールが ‘en’ の場合は ‘ja’ に設定し、
// ‘ja’ の場合は ‘en’ に設定する
setLocale(currentLocale === ‘en’ ? ‘ja’ : ‘en’);
};`

Use code with caution.

content_copy

上記のように、locale.value を使用して、locale 変数の現在の値を取得することができます。

解決策 2:

JavaScriptconst changeLocale = () => { // 現在のロケールが 'en' の場合は 'ja' に設定し、 // 'ja' の場合は 'en' に設定する setLocale(locale === 'en' ? 'ja' : 'en', locale.value); };

Use code with caution.

content_copy

上記のように、setLocale() 関数の第 2 引数に locale.value を渡すことで、現在のロケールと比較することができます。

agency-os/pages/posts/index.vue

		const pagePromise = useDirectus(
			readSingleton('pages_blog', {
				// deep: {
				// 	translations: {
				// 		_filter: {
				// 			languages_code: { _eq: languageCode},
				// 		},
				// 	},
				// },
				fields: [
					'*',
					{
						seo: ['*'],
						featured_post: [
							'id',
							'slug',
							//'title',
							//'summary',
							'type',
							{ image: ['id', 'title', 'description'], author: ['name', 'image', 'job_title'], translations: ['languages_code', 'title', 'summary'] },
						],
					},
				],
			}),
		);

agency-os/components/post/PostCard.vue

<script setup lang="ts">
const locale = useI18n().locale;
const currentLocale = locale.value;

const translations = unref(props.post.translations);
const localeTranslation = translations?.find((translation) => translation.languages_code === currentLocale );
</script>
<template>
<div class="flex flex-col justify-between h-full gap-3">
			<div>
    			現在のロケール: <span :textContent="locale"></span>
  			</div>
			<NuxtLink class="space-y-4" :href="`/posts/${post.slug}`">
				<TypographyHeadline
					v-if="localeTranslation?.title"
					:content="localeTranslation.title"
					class="group-hover:text-primary"
					size="xs"
					as="h3"
				/>
				<VText text-color="light" class="line-clamp-2">
					{{ localeTranslation?.summary }}
				</VText>
			</NuxtLink>

			<Author v-if="post.author" size="sm" :author="post.author as Team" />
		</div>
</template>

:warning:

DirectusをDevモードで起動したことに起因。ビルド後起動することで以下の問題はクリア。

セキュアウェブソケット:wssエラー

Nginxのリバースプロキシの設定で、以下のディレクトリへのアクセスをwssのポートへ誘導、docker-composeファイルでもそのポートを開放。

# For nuxt hmr
    location /_nuxt/ {
				proxy_pass http://172.22.0.1:24678;
				proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
		}

SyntaxError: Failed to construct ‘WebSocket’: The URL ‘wss://localhost:undefined/’ is invalid

geminiによる説明

上記のNuxt config設定は、ViteのHMR (Hot Module Replacement) をWSS (WebSocket Secure Sockets Layer) 経由で実行するための設定です。

詳細:

  • hooks オブジェクトは、Nuxt 3で導入された新しい機能で、ビルドプロセスや開発サーバーの動作をカスタマイズするために使用できます。
  • 'vite:extendConfig' フックは、Viteの内部設定を拡張するために使用できます。
  • 上記の設定では、viteInlineConfig.server オブジェクトに以下の設定を追加しています。
    • hmr.protocol: HMR接続に使用するプロトコルを指定します。この設定では、wss を指定しています。
    • hmr.port: HMRサーバーのポート番号を指定します。デフォルトは 8080 です。

この設定の利点:

  • WSSを使用することで、HMR接続を暗号化できます。
  • WSSを使用することで、HMR接続をより安全にすることができます。

この設定の注意事項:

  • WSSを使用するには、ファイアウォール設定でWSSポートを開放する必要があります。
  • WSSを使用するには、クライアント側でWSS接続をサポートする必要があります。

ViteのHMR (Hot Module Replacement) とは?

ViteのHMRは、開発中にコードを変更した際に、ブラウザをリロードせずに自動的に更新を反映する機能です。従来の開発ワークフローでは、コードを変更するたびにブラウザをリロードする必要があり、時間がかかり、煩わしいものでした。HMRはこの問題を解決し、開発効率を大幅に向上させます。

HMRの仕組み

HMRは、WebSocketを使用してブラウザとサーバー間の双方向通信を実現します。コードに変更が加えられると、ViteサーバーはWebSocketを通じてクライアントに通知します。クライアントは変更されたモジュールのみをダウンロードし、ブラウザに反映します。

HMRの利点

  • 高速な開発: ブラウザのリロードを待つ必要がないため、開発速度が大幅に向上します。
  • 効率的なワークフロー: コード変更の影響をすぐに確認できるため、デバッグやテストが容易になります。
  • 優れた開発体験: ブラウザのリロードによる画面のちらつきがなく、スムーズな開発体験を提供します。

HMRの使用方法

HMRは、Viteプロジェクトでデフォルトで有効になっています。特別な設定は必要ありません。コードを変更して保存すると、HMRが自動的に更新を反映します。

フロントエンドテンプレート

:warning: NuxtのDevツールを利用する場合には、必ずhttp://localhost:3000を利用すること。


AgencyOS(テンプレート)のインストール

テンプレートデータのインポート

注)事前に管理画面でTokenを作成

注)インポート先のURLとしてdirectusコンテナのアドレス:ポートを指定すること

Directusコンテナ内でAgencyOSのテンプレートデータをインポート(JSONフォーマットデータに対応したPostgreSQLが必須条件)

$ docker compose exec directus sh
/directus $ npx directus-template-cli@latest apply

Need to install the following packages:
[email protected]
Ok to proceed? (y) 
? What type of template would you like to apply? Official templates
? Select a template. AgencyOS
You selected AgencyOS
------------------
What is your Directus URL?: http://172.18.0.4:8055
What is your Directus Admin Token?: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
------------------
Loaded Schema
Applying template - AgencyOS... ⣽
Loaded Roles
Loaded Folders
Loaded Files
Loaded Users
Loaded Dashboards
Loaded Panels
Loaded Data
Loaded Flows
Loaded Operations
Loaded Presets
Loaded Settings
Applying template - AgencyOS... ⡿
Loaded Permissions
Applying template - AgencyOS... done
------------------
Template applied successfully.

テンプレートフロントエンド起動

ソースをダウンロード・ディレクトリへ移動

$ git clone https://github.com/directus-community/agency-os.git
$ cd agency-os

.envファイルを作成

.env

# Directus Setup
DIRECTUS_URL="https://your-instance.directus.app"
DIRECTUS_SERVER_TOKEN="your_directus_server_token_for_server_only_routes"
SITE_URL="http://localhost:3000"

pnpmを有効にして関連パッケージをダウンロード・テンプレートを起動

$ corepack enable pnpm
$ pnpm i
$ pnpm dev

pnpmとnpmの違い

pnpmとnpmは、JavaScriptプロジェクトで依存関係を管理するために使用されるパッケージマネージャーです。どちらも多くの機能を共有していますが、いくつかの重要な違いがあります。

1. ディスク容量の使用量

pnpmは、npmと比較してディスク容量の使用量が大幅に少ないことが特徴です。これは、pnpmが依存関係をグローバルに保存し、シンボリックリンクを使用してプロジェクトにインストールするためです。一方、npmは各プロジェクトに依存関係の個別のコピーをインストールします。

2. インストール速度

pnpmは、npmよりもインストール速度が速いことが特徴です。これは、pnpmが依存関係を並行してインストールし、キャッシュを使用して以前インストールされた依存関係を再利用するためです。

3. 厳格なパッケージ管理

pnpmは、npmよりも厳格なパッケージ管理を提供します。これは、pnpmが依存関係のバージョンを厳密に管理し、互換性のないバージョンをインストールできないようにするためです。

4. ワークスペース

pnpmは、ワークスペースと呼ばれる機能を提供します。ワークスペースは、プロジェクトのグループをまとめて管理するための方法です。ワークスペースを使用すると、プロジェクト間で依存関係を共有したり、異なるバージョンの依存関係を異なるプロジェクトで使用したりすることができます。

5. コミュニティ

npmは、pnpmよりも大きなコミュニティとエコシステムを持っています。これは、npmがより長い歴史を持つためです。しかし、pnpmのコミュニティは急速に成長しており、多くの新しいツールやライブラリがpnpmをサポートしています。


注)directus側でCORSを有効にすること。

http://localhost:3000

コンテナとして起動する場合

Agency-OSはpnpmによりパッケージを管理しているため、pnpmがデフォルトで有効になるようにdebianベースのイメージファイルから新たなイメージを作成。

dockerfile

FROM debian:stable-slim
RUN apt-get update && apt-get install -y nano curl git
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && apt-get install -y nodejs
RUN corepack enable pnpm

新規イメージのビルド

$ docker build --no-cache -t debian:node20-slim .

注)Agency-OSの.envファイルのDIRECTUS_URLはホストマシンのローカルアドレス:ポート番号とすること(コンテナから見たlocalhostは、ホストマシンのlocalhostではないため)。

各パッケージのインストールとデバックモードで起動

$ docker run -it --rm --name my-running-script -p 3000:3000 -v ./agency-os:/app -w /app debian:node20-slim bash -c "pnpm i && pnpm dev"

docker-composeファイルに追加する場合は以下のセクションを追加。
または独立した専用のdocker-composeファイルを作成(ネットワークはDirectusと共有)。entrypointとして初回起動時はbashを指定し、コンテナ内でパッケージのインストール、ビルドなどを行うこと。ビルド後は静的ページが作成されるので、プロダクションモード(nodeコマンド)でコンテナを起動。

  agency-os:
    container_name: agency-os
    ### custom image created by dockerfile
    image: debian:node20-slim
    tty: true
    restart: unless-stopped
    working_dir: /app
    volumes:
      - ./agency-os:/app
    ports:
      - "3000:3000"
#    entrypoint: ["/bin/bash"] # Initial entry point. need to execute "pnpm i" then "pnpm build" or start debug mode "pnpm dev"
    entrypoint: ["node", ".output/server/index.mjs"] # after "pnpm build" for running static pages
    networks: 
      proxy-tier:
        ipv4_address: 172.18.0.5
    depends_on:
      - directus

パッケージを再インストールする場合は、node_modules と pnpm-lock.yaml ファイルを削除してから再インストール pnpm i を実行。

# rm -rf node_modules pnpm-lock.yaml
# pnpm i

API

Request

REST

GET /server/specs/oas

GET /server/specs/graphql/

GET /server/specs/graphql/system

GET /server/ping

GET /server/info

GET /items/pages

Postmanで確認

environmentのbase_urlにdirectusのアドレスを入力してAPIの動作を確認

Curlコマンドで確認

https://linuxize.com/post/curl-rest-api/

GET : -X GET はデフォルト

$ curl https://jsonplaceholder.typicode.com/posts

$ curl https://jsonplaceholder.typicode.com/posts?userId=1

POST

$ curl -X POST -d "userId=5&title=Hello World&body=Post body." https://jsonplaceholder.typicode.com/posts

PUT

$ curl -X PUT -d "userId=5&title=Hello World&body=Post body." https://jsonplaceholder.typicode.com/posts/5

PATCH

$ curl -X PUT -d "title=Hello Universe" https://jsonplaceholder.typicode.com/posts/5

DELETE

$ curl -X DELETE https://jsonplaceholder.typicode.com/posts/5

多言語対応

Directus側(バックエンド)とNuxt側(フロントエンド)で対応。

Directus

翻訳ページの設定

API

Nuxt

多言語対応パッケージのインストール

# pnpm add -D @nuxtjs/i18n

デフォルト言語を英語、英語と日本語に対応する場合の設定例
nuxt.config.ts

export default defineNuxtConfig({
    modules: ['@nuxtjs/i18n']

	// Internationalization - https://i18n.nuxtjs.org/
	i18n: {
		locales: [
			{
				code: 'en',
				name: 'EN',
				iso: 'en-US',
				file: 'en-US.js'
			},
		  	{
		    	code: 'ja',
		    	name: 'JP',
		    	iso: 'ja-JP',
				file: 'ja-JP.js'
		  	}
		],
		strategy: 'prefix_except_default',
		lazy: true,
		langDir: 'lang',
		defaultLocale: 'en', // default locale of your project for Nuxt pages and routings
		detectBrowserLanguage: {
			useCookie: false
		}
    },

ナビゲーションバーへ追加する言語切替スイッチコンポーネントを新規に作成

components/LangSwitcher.vue

<script setup lang="ts">
export interface DarkModeToggleProps {
	bg?: 'dark' | 'light';
}

withDefaults(defineProps<DarkModeToggleProps>(), {
	bg: 'light',
});

const colorMode = useColorMode();

const isDark = computed({
	get() {
		return colorMode.value === 'dark';
	},
	set() {
		colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark';
	},
});

const { locale, locales, setLocale } = useI18n()
const switchLocalePath = useSwitchLocalePath()

const availableLocales = computed(() => {
  return locales.value.filter(i => i.code !== locale.value)
})
</script>

<template>
  <ClientOnly>
    <UButton
      v-for="locale in availableLocales"
      :key="locale.code"
      variant="ghost"
      class="text-white"
      @click.prevent.stop="setLocale(locale.code)"
      :icon="locale.code === 'en' ? 'flag:gb-4x3' : 'flag:jp-4x3'"
    > </UButton>
    <template #fallback>
      <div class="w-10 h-10" />
    </template>
  </ClientOnly>
</template>

ヘッダーテンプレートに<LangSwitcher>ブロック追加

components/navigation/TheHeader.vue

<template>
	<header class="relative w-full mx-auto space-y-4 md:flex md:items-center md:space-y-0 md:gap-x-4">
		<div class="flex items-center bg-gray-900 justify-between py-2 px-6 md:flex-1 rounded-card">
			<NuxtLink href="/" class="py-2">
				<Logo class="h-8 text-white" />
				<span class="sr-only">{{ title }}</span>
			</NuxtLink>
			<nav class="hidden md:flex md:space-x-4 lg:space-x-6" aria-label="Global">
				<NavigationMenuItem v-for="item in navigation?.items" :key="item.id" :item="item" />
			</nav>
			<div class="flex items-center justify-end flex-shrink-0 space-x-2">
				<DarkModeToggle class="hidden text-gray-200 md:block hover:text-gray-400" bg="dark" />
			</div>
			<div class="flex items-center justify-end flex-shrink-0 space-x-2">
				<LangSwitcher class="text-gray-200 md:block hover:text-gray-400" bg="dark" />
			</div>			

			<!-- div class="hidden h-full gap-4 md:flex">
				<UButton to="/contact-us" color="primary" size="xl">Let's Talk</UButton>
				<UButton to="/portal" color="primary" variant="ghost" size="xl">Login</UButton>
			</div -->
		</div>
		<NavigationMobileMenu v-if="navigation" :navigation="navigation" />
	</header>
</template>

テンプレートがロケールフィールドを変数として取得する場合

xxx.vue

<script setup lang="ts">
const { currentLocale, localeTranslation } = usePostTranslations(props.post);

function usePostTranslations(post: Post) {
    const currentLocale = useI18n().locale.value;

    const translations = unref(post.translations);
    const localeTranslation = translations?.find((translation) => translation.languages_code === currentLocale );

    return { currentLocale, localeTranslation };
};
</script>
<template>
			<NuxtLink class="space-y-4" :href="`/${currentLocale}/posts/${post.slug}`">
				<TypographyHeadline
					v-if="localeTranslation?.title"
					:content="localeTranslation.title"
					class="group-hover:text-primary font-mono"
					size="xs"
					as="h3"
				/>
				<VText text-color="light" class="line-clamp-2">
					{{ localeTranslation?.summary }}
				</VText>
			</NuxtLink>
</template>

テンプレートがロケールフィールドを関数から取得する場合

xxx.vue

<script setup lang="ts">
// Access the translated title based on locale
const currentLocale = useI18n().locale.value;

function usePostTranslations(page: any) {
	const translations = page?.translations?.find((translation) => translation.languages_code === currentLocale);
	return translations;
}
</script>
<template>
				<div class="relative">
					<TypographyHeadline :content="usePostTranslations(page)?.title" as="h1" size="lg" />
					<TypographyProse :content="usePostTranslations(page)?.summary" class="mt-2" />
				</div>
</template>

SEO対応

タグにlang=ja-JP or en-USを追加

agency-os/layouts/default.vue

<script setup lang="ts">
const head = useLocaleHead({
  addDirAttribute: true,
  identifierAttribute: 'id',
  addSeoAttributes: true
})
</script>
<template>
<div .......>
.....
.....
<Html :lang="head.htmlAttrs.lang" :dir="head.htmlAttrs.dir">
.....
.....
</Html>
</div>
</template>

Parameters

options

An object accepting the following optional fields:

  • addDirAttribute
    Type: Boolean
    Adds a dir attribute to the HTML element. default false.
  • addSeoAttributes
    Type: boolean | SeoAttributesOptions
    Adds various SEO attributes. default false.
  • identifierAttribute
    Type: String
    Identifier attribute of <meta> tag, default 'hid'.

サイトのカスタマイズ

Nuxt.jsとTailwindによるフロントエンドデザイン

フレームワーク:Nuxt, Vue.js

CSS:Tailwind

CSSクラスでダークモードとライトモードの指定可

tailwind.config.ts

.....
.....
export default {
	darkMode: 'class',
.....
.....

グリッド


フォームのビューモードをダークで固定

components/base/UForm.vue

.....
.....
		<div>
			<FormCustom

				class="grid gap-6 md:grid-cols-6 dark"
		
			/>
		</div>
.....
.....

アイコン(Nuxtモジュール)

Add 200,000+ ready to use icons to your Nuxt application, based on Iconify.


デフォルトの表示設定

layouts/default.vue

<script setup lang="ts"></script>
<template>
	<div
		class="relative flex flex-col min-h-screen overflow-hidden antialiased transition duration-150 bg-white visual-effects bg-gradient-to-br from-white to-gray-100 dark:from-gray-950 dark:to-gray-700"
	>
		<div class="relative w-full max-w-6xl px-6 pt-6 mx-auto lg:px-16">
			<NavigationTheHeader />
		</div>

		<div class="relative text-gray-900">
			<slot />
		</div>

		<div class="relative w-full max-w-6xl px-2 pt-6 pb-12 mx-auto lg:px-16">
			<NavigationTheFooter />
		</div>

		<div class="fixed z-10 bottom-4 left-4">
			<!-- <ScrollTop /> -->
		</div>
	</div>
</template>

ページの割付

<script setup lang="ts">
const page = ref(1)
const items = ref(Array(55))
</script>

<template>
  <UPagination v-model="page" :page-count="5" :total="items.length" />
</template>

デフォルト表示モード:ライト/ダーク

agency-os/components/DarkModeToggle.vue

<script setup lang="ts">
export interface DarkModeToggleProps {
	bg?: 'dark' | 'light';
}

withDefaults(defineProps<DarkModeToggleProps>(), {
	bg: 'dark',
});

グローバルパラメータ

Deep

リレーショナルデータを条件付する場合

{
	"related_articles": {
		"_limit": 3,
		"comments": {
			"_sort": "rating",
			"_limit": 1
		}
	}
}

フィルタールール

言語による条件付け

const pages = await directus.request(
  readItems('articles', {
    deep: {
      translations: {
        _filter: {
          languages_code: { _in: ['en', 'ja'] },
        },
      },
    },
    fields: ['*', { translations: ['*'] }],
    limit: 1,
  })
);

Filter Operators

Operator Title (in app) Operator Description
Equals _eq Equal to
Doesn’t equal _neq Not equal to
Less than _lt Less than
Less than or equal to _lte Less than or equal to
Greater than _gt Greater than
Greater than or equal to _gte Greater than or equal to
Is one of _in Matches any of the values
Is not one of _nin Doesn’t match any of the values
Is null _null Is null
Isn’t null _nnull Is not null
Contains _contains Contains the substring
Contains (case-insensitive) _icontains Contains the case-insensitive substring
Doesn’t contain _ncontains Doesn’t contain the substring
Starts with _starts_with Starts with
Starts with _istarts_with Starts with, case-insensitive
Doesn’t start with _nstarts_with Doesn’t start with
Doesn’t start with _nistarts_with Doesn’t start with, case-insensitive
Ends with _ends_with Ends with
Ends with _iends_with Ends with, case-insensitive
Doesn’t end with _nends_with Doesn’t end with
Doesn’t end with _niends_with Doesn’t end with, case-insensitive
Is between _between Is between two values (inclusive)
Isn’t between _nbetween Is not between two values (inclusive)
Is empty _empty Is empty (null or falsy)
Isn’t empty _nempty Is not empty (null or falsy)
Intersects _intersects [1] Value intersects a given point
Doesn’t intersect _nintersects [1] Value does not intersect a given point
Intersects Bounding box _intersects_bbox [1] Value is in a bounding box
Doesn’t intersect bounding box _nintersects_bbox [1] Value is not in a bounding box

The following operator has no Title on the Filter Interface as it is only available in validation permissions:

Operator Description
_regex [2] Field has to match regex

[1] Only available on Geometry types.
[2] JavaScript “flavor” regex. Make sure to escape backslashes.

データリレーション

データモデルを構築する上で事前に理解が必要。

Many-to-Many (M2M)

recipes
- id
- name
- ingredients (An M2M alias field. Does not exist in the database, allows access to ingredients linked from recipe_ingredients)
recipes_ingredients (junction collection)
- id
- recipe (stores foreign key from recipes.id)
- ingredient (stores foreign key from ingredients.id)
- quantity (A "context" field. Stores other data associated with the relationship. These are optional.)
ingredients
- id
- name
- recipes (an M2M alias field, does not exist in the database, enables access to all the recipes related to an ingredient)

Many-to-Any (M2A)

pages
- id
- name
- sections (An M2A alias field, does not exist in the database. Provides access to items from page_sections.)
page_sections (junction collection)
- id
- pages_id (An M2O, stores foreign keys from pages.id)
- collection (An M2O, stores name of the related collection, for example headings, text_bodies, or images.)
- item (An M2O, stores foreign keys from headings.id, text.id, images.id, etc.)
headings
- id
- title
text_bodies
- id
- text
images
- id
- file

Nuxt DevTools

devtoolsのアクティブ/非アクティブ化(通常は開発段階 pnpm dev でのみ利用される機能なのでアクティブのままを保持)

agency-os/nuxt.config.ts

devtools: { enabled: true or false },

サイトマップ

サイトマップモジュールは、nuxt-simple-sitemapから@nuxtjs/sitemapへ移行。

アップデート(コンテナ内で実行)

# pnpm i @nuxtjs/sitemap@latest

https://<domain>/sitemap.xml

agency-os/nuxt.config.ts

 	runtimeConfig: {
		public: {
			siteUrl: process.env.SITE_URL,
		},
                // added  the below
		directus: {
			apiUrl: process.env.DIRECTUS_URL,
			apiToken: process.env.DIRECTUS_SERVER_TOKEN,
		},
	},

	sitemap: {
		exclude: [
		  '/auth/**',
		  '/portal/**',
		],
		sources: [
			'/api/__sitemap__/urls',
		],
	},

以下のサイトマップを指定するソースファイルを追加。

agency-os/server/api/__sitemap__/urls.ts

export default defineSitemapEventHandler(async () => {

    const config = useRuntimeConfig();
    const apiUrl = config.directus.apiUrl;
    const apiToken = config.directus.apiToken;

    const [posts, pages] = await Promise.all([

      $fetch<{ data: { slug: string }[] }>(`${apiUrl}/items/posts`, {
        params: { fields: 'slug', filter: { status: { _eq: 'published' } } },
        headers: { Authorization: `Bearer ${apiToken}` },
      }).then(posts =>
        posts.data.map(p => ({
          loc: `/posts/${p.slug}`,
          _i18nTransform: true,
        }))
      ),

      $fetch<{ data: { permalink: string }[] }>(`${apiUrl}/items/pages`, {
        params: { fields: 'permalink', filter: { status: { _eq: 'published' } } },
        headers: { Authorization: `Bearer ${apiToken}` },
      }).then(pages =>
        pages.data.map(p => ({
          loc: `${p.permalink}`,
          _i18nTransform: true,
        }))
      ),
    ]);

    return [...posts, ...pages];
});

Google関連ファイル

Google Adsense関連ファイルとロボットテキストは、以下のディレクトリに格納すること。

agency-os/public/ads.txt
agency-os/public/googlexxxxxx.html
agency-os/public/robots.txt

Markdownフォーマットのレンダリング

markdown-itモジュールのインストール

依存関係から@directus/typesのアップデート

# pnpm install @directus/[email protected]

markdown-it, @types/markdown-it のインストール

# pnpm add -D markdown-it @types/markdown-it

役割の違い

  • markdown-it: 実際の Markdown パーサーの実装を提供します。
  • @types/markdown-it: markdown-it の型定義ファイルを提供します。

型定義ファイルの役割

型定義ファイルは、TypeScript コンパイラにモジュールの型情報を提供します。型情報を提供することで、コンパイラはコードの型チェックを行い、潜在的なエラーを検出することができます。

コードハイライト:highlight.jsもインストール

# pnpm add -D highlight.js

The default import will register all languages:

import hljs from 'highlight.js';

If your build tool processes CSS imports, you can also import the theme directly as a module:

import hljs from 'highlight.js'; 
import 'highlight.js/styles/github.css';

Markdownコンテンツをレンダリングするvueファイルを新規に作成

agency-os/components/typography/Prosemd.vue

<script setup lang="ts">
import markdownit from 'markdown-it';
import hljs from 'highlight.js'; // https://highlightjs.org
import 'highlight.js/styles/atom-one-dark.css';


export interface ProseProps {
    content: string;
    size?: 'sm' | 'md' | 'lg'; // @TODO: Rework the sizes
};

withDefaults(defineProps<ProseProps>(), {
    size: 'md',
});


const md = markdownit({
  html: true,
  linkify: true,
  typographer: true,
  highlight: function (str, lang) {
    if (lang && hljs.getLanguage(lang)) {
      try {
        return hljs.highlight(str, { language: lang }).value;
      } catch (__) {}
    }

    return ''; // use external default escaping
  },
});
</script>
<template>
    <div
        :class="[
            {
                'prose-sm': size === 'sm',
                'md:prose-base lg:prose-lg': size === 'lg',
                'prose-lg lg:prose-xl': size === 'lg',
            },
            'prose dark:prose-invert prose-img:rounded-lg prose-headings:font-mono prose-headings:font-semibold',
            'w-full max-w-3xl mx-auto mt-12',
        ]"
    v-html="md.render(content)"
    />
</template>

DevモードからProductionモードへの移行

agency-os/.env

コンテナのローカルIPアドレスからドメインへ変更

変更前

DIRECTUS_URL="http://172.xx.xx.1:8055"
DIRECTUS_SERVER_TOKEN="cE4_7r4JaphkuYDhnSjnXZE9-kAGlbNI"
NUXT_PUBLIC_SITE_URL="http://172.xx.xx.1:3000"

変更後

DIRECTUS_URL="https://api_test.example.com"
DIRECTUS_SERVER_TOKEN="xxxxxxxxxxxxxxxxxxxxxx"
NUXT_PUBLIC_SITE_URL="https://test.example.com"

agency-os/nuxt.config.ts

環境変数で指定されているので http://localhost:3000 はそのままで可。

siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'http://localhost:3000',

データベースの操作

ローカル環境で作成したコンテンツの画像リンク先を修正。

ビルド

コンテナ内でビルド

$ docker compose exec agency-os bash
# pnpm build

pnpm dev から pnpm start へ変更

コンテナ内でビルドコマンドを実行(コードを編集した場合やコンテンツを追加した場合にも実行)。

# pnpm build

上記ビルドコマンドを実行し./outputフォルダが作成されたらnodeコマンドで起動。

docker-compose.yaml

#   entrypoint: ["/bin/bash"] # Initial entry point. need to execute "pnpm i" then "pnpm build" or start debug mode "pnpm dev"
    entrypoint: ["node", ".output/server/index.mjs"] # after "pnpm build" for running static pages

環境変数は以下参照。必要に応じてdocker-compose.yamlのenvironmentセクション、または.envファイルに記載。

上記スクリプトコマンドは以下参照。

agency-os/package.json

{
	"private": true,
	"scripts": {
		"build": "nuxt build",
		"dev": "nuxt dev",
		"start": "nuxt start",
		"generate": "nuxt generate",
		"preview": "nuxt preview",
		"postinstall": "nuxt prepare",
		"typecheck": "nuxt typecheck",
		"lint": "eslint --cache .",
		"format": "prettier --write \"**/*.{md,y?(a)ml,json,vue}\""
	},

コンテナ内でドメイン名を解決するため、directusとagency-os各サービスに以下のセクションを追加。

ただし、directusのセクションのみ
PUBLIC_URL: 'http://api_test.ficusonline.com'
を追加

docker-compose.yaml

### directus,agency-osセクションに追加(Dev・Productionモードで共に指定可)
extra_hosts:
      - "api.example.com:172.22.0.1"
      - "www.example.com:172.22.0.1"

### directusセクションのみ追加(Devモードではコメントアウト)
environment:
  PUBLIC_URL: 'https://api_test.example.com'

トラブルシュート

agency-osビルドエラー

アプリケーションに割当てられるメモリーが少ないため発生するエラー。
pnpmによるdev.build,start各コマンドを、メモリーサイズオプションを追加したnodeコマンドで代替。

agency-os/package.json

{
	"private": true,
	"scripts": {
		"build": "node --max-old-space-size=4096 node_modules/nuxt/bin/nuxt.mjs build",
		"dev": "node --max-old-space-size=4096 node_modules/nuxt/bin/nuxt.mjs dev",
		"start": "node --max-old-space-size=4096 node_modules/nuxt/bin/nuxt.mjs start",
		"generate": "nuxt generate",
		"preview": "nuxt preview",
		"postinstall": "nuxt prepare",
		"typecheck": "nuxt typecheck",
		"lint": "eslint --cache .",
		"format": "prettier --write \"**/*.{md,y?(a)ml,json,vue}\""
	},

OG(Open Graph) イメージ

OGイメージの作成例

pages/index.vue

<script lang="ts" setup>
defineOgImageComponent('NuxtSeo')
</script>

Devツールで表示確認

(Ctrl + K ) and typing og .

出力結果


Nuxt Color Modeとの併用

メタデータ

SEO対策としてメタデータを作成

メタデータをサーバサイドで作成:useServerSeoMeta

AgencyOSアップデート

Directusをv.10からv.11へアップデート

directus-template-cliのv.11への対応

注)既に対応済のため下記は参考扱い

一度テンプレートをバックアップし…

# npx directus-template-cli@beta extract

上記テンプレートを読み込みます

# npx directus-template-cli@beta apply

Directus v.11で変更となったポリシーに基づいたアクセス制限

ユーザロールとアクセスポリシーへの対応


SEO対策


コミット履歴に従いコード修正、不要となったファイルを削除


パッケージ更新(Node v.23のDockerイメージを使用)

dockerfile

###  This dockerfile: to build the new image for the pnpm package manager using Agency-OS
# $ docker build --no-cache -t debian:node23-slim .

### Start Agency-OS frontend as a container with the created image file
# $ docker run -it --rm --name my-running-script -p 3000:3000 -v ./agency-os:/app -w /app debian:node23-slim bash -c "pnpm i && pnpm dev"

### https://hub.docker.com/_/debian
FROM debian:stable-slim
RUN apt-get update && apt-get install -y nano curl
RUN curl -fsSL https://deb.nodesource.com/setup_23.x | bash - && apt-get install -y nodejs
RUN corepack enable pnpm

package.json

{
	"type": "module",
	"private": true,
	"scripts": {
		"build": "node --max-old-space-size=4096 node_modules/nuxt/bin/nuxt.mjs build",
		"dev": "node --max-old-space-size=4096 node_modules/nuxt/bin/nuxt.mjs dev",
		"start": "node --max-old-space-size=4096 node_modules/nuxt/bin/nuxt.mjs start",
		"generate": "node --max-old-space-size=4096 node_modules/nuxt/bin/nuxt.mjs generate",
		"preview": "nuxt preview",
		"postinstall": "nuxt prepare",
		"typecheck": "nuxt typecheck",
		"lint": "eslint --cache .",
		"format": "prettier --write \"**/*.{md,y?(a)ml,json,vue}\""
	},
	"devDependencies": {
		"@iconify-json/heroicons": "^1.2.2",
		"@iconify-json/material-symbols": "^1.2.12",
		"@iconify-json/mdi": "^1.2.2",
		"@nuxtjs/eslint-config-typescript": "^12.1.0",
		"@nuxtjs/eslint-module": "^4.1.0",
		"@nuxtjs/tailwindcss": "^6.12.2",
		"@tailwindcss/forms": "^0.5.9",
		"@tailwindcss/typography": "^0.5.15",
		"@types/uuid": "^10.0.0",
		"@vueuse/core": "^12.3.0",
		"@vueuse/nuxt": "^12.3.0",
		"eslint": "8.57.1",
		"eslint-config-prettier": "^9.1.0",
		"eslint-plugin-prettier": "^5.2.1",
		"eslint-plugin-vue": "^9.32.0",
		"prettier": "^3.4.2",
		"typescript": "^5.7.2",
		"vue-tsc": "2.1.6"
	},
	"dependencies": {
		"@directus/sdk": "^18.0.3",
		"@formkit/auto-animate": "^0.8.2",
		"@headlessui/vue": "^1.7.23",
		"@nuxt/icon": "^1.10.3",
		"@nuxt/image": "^1.8.1",
		"@nuxt/ui": "^2.20.0",
		"@nuxtjs/color-mode": "^3.5.2",
		"@nuxtjs/google-adsense": "^3.0.0",
		"@nuxtjs/google-fonts": "^3.2.0",
		"@nuxtjs/i18n": "^9.0.0",
		"@nuxtjs/seo": "^2.0.2",
		"@nuxtjs/sitemap": "^7.0.1",
		"@stripe/stripe-js": "^4.10.0",
		"@vueuse/motion": "^2.2.6",
		"highlight.js": "^11.11.1",
		"jwt-decode": "^4.0.0",
		"markdown-it": "^14.1.0",
		"micromark": "^4.0.1",
		"micromark-extension-gfm": "^3.0.0",
		"nuxt": "^3.15.0",
		"nuxt-og-image": "^4.0.2",
		"stripe": "^16.12.0",
		"uuid": "^10.0.0",
		"v-perfect-signature": "^1.4.0",
		"vue": "^3.5.13",
		"vue-dompurify-html": "^5.2.0"
	},
	"packageManager": "[email protected]",
	"engines": {
		"node": ">=18.0.0",
		"pnpm": ">=8.6.0"
	}
}