انتقل إلى المحتوى فهرس التوثيق لوكلاء الذكاء الاصطناعي (llms.txt)
LaRecipe
3.x

الإصدارات

التخصيصمكوّنات React المخصّصة

مكوّنات React المخصّصة

اكتب مكوّناتك الخاصة بـ React، واستخدمها في Markdown كالمكوّنات المدمجة، وأعطِ كلًّا منها بديلًا يعمل من دون JavaScript وصيغة Markdown للوكلاء.

حُدّث قراءة 14 دقيقة

حين لا تكفي المكوّنات المدمجة (نموذج اشتراك في نشرة بريدية، أو جدول أسعار، أو شارة حالة حيّة)، اكتب مكوّنك الخاص بـ React واستخدمه في Markdown كأي مكوّن مدمج: <Subscribe list="larecipe" />. للمكوّن ثلاثة أوجه، وتكتبها كلها:

  • React، للقرّاء الذين يشغّل متصفحهم JavaScript.
  • HTML، بديل (fallback) من الخادم للقرّاء بلا JavaScript، ولمحركات الزحف، وللرسم الأول.
  • Markdown، ما يحصل عليه الوكلاء حين يقرؤون الصفحة بصيغة .md أو عبر MCP.

نموذج النشرة البريدية في آخر المقدمة واحد منها. وهذه الصفحة تبنيه في أربع خطوات، مباشرةً من كود هذا الموقع:

  1. اكتب مكوّن React.
  2. سجّله في ملف دخول الامتداد.
  3. عرّفه في PHP، مع بديله وصيغة Markdown الخاصة به.
  4. ابنِه باستخدام Vite.

ثم استخدمه في صفحة، أو في كل صفحة.

كل ملف يحتاج إليه نموذج Subscribe
  • app/
    • Http/Controllers/SubscribeController.php
    • Providers/AppServiceProvider.php
  • resources/
    • larecipe/
      • extension.tsx
      • Subscribe.tsx
      • subscribe.css
    • views/
      • docs/subscribe.blade.php
      • newsletter.blade.php
  • routes/web.php
  • vite.config.js

مكوّن React

المكوّن المخصّص مكوّن React عادي. خصائصه (props) هي سمات الوسم، و Markdown الواقع بين وسم فتح ووسم إغلاق يصل على شكل children. وكل ما يستورده من @larecipe/ui يوفّره LaRecipe وقت التشغيل.

resources/larecipe/Subscribe.tsx
import { useState, type FormEvent } from 'react'
import { Button, Icon, Input, request, useLaRecipe } from '@larecipe/ui'

/** The form's words in each language the site serves (lang/{en,ar}/newsletter.php on the server). */
const STRINGS = {
  en: {
    label: 'Newsletter',
    title: 'Stay in the loop',
    body: 'LaRecipe release notes by email. Unsubscribe any time.',
    email: 'Email address',
    submit: 'Subscribe',
    sending: 'Subscribing',
    doneTitle: 'You are on the list',
    doneBody: (email: string) => `The next release note goes to ${email}.`,
    failed: 'That did not work. Please try again.',
    note: 'This form is a custom React component this site adds to LaRecipe.',
    noteLink: 'See how it is built',
  },
  ar: {
    label: 'النشرة البريدية',
    title: 'ابقَ على اطلاع',
    body: 'ملاحظات إصدارات LaRecipe عبر البريد الإلكتروني. يمكنك إلغاء الاشتراك في أي وقت.',
    email: 'عنوان البريد الإلكتروني',
    submit: 'اشترك',
    sending: 'جارٍ الاشتراك',
    doneTitle: 'أنت الآن في القائمة',
    doneBody: (email: string) => `ستصل ملاحظة الإصدار التالي إلى ${email}.`,
    failed: 'لم ينجح ذلك. حاول مرة أخرى.',
    note: 'هذا النموذج مكوّن React مخصّص يضيفه هذا الموقع إلى LaRecipe.',
    noteLink: 'شاهد كيف بُني',
  },
}

/** Where "See how it is built" goes: the custom components guide, in the reader's language and version. */
function guideUrl(base: string, locale: string, version: string): string {
  return `${base.replace(/\/$/, '')}${locale === 'ar' ? '/ar' : ''}/${version}/customize/custom-components`
}

/**
 * The newsletter form on every docs page (the article.after insert point) and
 * inline on the Introduction (<Subscribe />).
 *
 * React comes from the page's import map, so this shares LaRecipe's React.
 * useLaRecipe() gives the page and its locale; request() posts to our own
 * route with the session cookie and the XSRF token, and hands back Laravel's
 * validation errors. The markup matches the server fallback in
 * resources/views/docs/subscribe.blade.php.
 */
export function Subscribe({ list = 'larecipe', showcase = false }: { list?: string; showcase?: boolean }) {
  const { page, locale, boot, version } = useLaRecipe()
  const lang = locale.code === 'ar' ? 'ar' : 'en'
  const t = STRINGS[lang]
  const [email, setEmail] = useState('')
  const [status, setStatus] = useState<'idle' | 'sending' | 'done'>('idle')
  const [error, setError] = useState<string | null>(null)

  async function submit(event: FormEvent) {
    event.preventDefault()
    setStatus('sending')
    setError(null)

    const response = await request<{ subscribed: boolean }>('/newsletter/subscribe', {
      method: 'POST',
      body: { email, list, source: page.path, locale: lang },
    })

    if (response.ok) {
      setStatus('done')
      return
    }

    setError(response.errors?.email?.[0] ?? response.message ?? t.failed)
    setStatus('idle')
  }

  const note = showcase ? (
    <p className="lr-subscribe-note">
      {t.note} <a href={guideUrl(boot.urls.base, lang, version)}>{t.noteLink}</a>
    </p>
  ) : null

  const head = (title: string, body: string) => (
    <div className="lr-subscribe-head">
      <span className="lr-subscribe-icon" aria-hidden="true">
        <Icon name="Mail" size={18} />
      </span>
      <div className="lr-subscribe-copy">
        <p className="lr-subscribe-label">{t.label}</p>
        <p className="lr-subscribe-title">{title}</p>
        <p className="lr-subscribe-body">{body}</p>
      </div>
    </div>
  )

  if (status === 'done') {
    return (
      <div className="lr-subscribe-wrap">
        <div className="lr-subscribe" data-lr-subscribe="react" role="status">
          {head(t.doneTitle, t.doneBody(email))}
        </div>
        {note}
      </div>
    )
  }

  return (
    <div className="lr-subscribe-wrap">
      <form className="lr-subscribe" data-lr-subscribe="react" onSubmit={submit}>
        {head(t.title, t.body)}
        <div className="lr-subscribe-row">
          <label className="lr-subscribe-field">
            <span className="lr-sr">{t.email}</span>
            <Input
              type="email"
              name="email"
              required
              autoComplete="email"
              placeholder="you@example.com"
              dir="ltr"
              value={email}
              aria-invalid={error ? true : undefined}
              onChange={(event) => setEmail(event.target.value)}
            />
          </label>
          <Button type="submit" variant="primary" loading={status === 'sending'}>
            {status === 'sending' ? t.sending : t.submit}
          </Button>
        </div>
        {error && (
          <p className="lr-subscribe-error" role="alert">
            {error}
          </p>
        )}
      </form>
      {note}
    </div>
  )
}
ما الذي يعطيه @larecipe/ui لمكوّنك
التصدير ما يفعله
useLaRecipe() الصفحة الحالية page (المسار والعنوان والروابط والإصدار واللغة و meta)، والمجموعة set (المفتاح والاسم والإصدارات واللغات)، واللغة locale (الرمز والاتجاه)، والمظهر theme (الوضع وتفضيل القارئ)، والمستخدم المسجّل user حين تشاركه، و t لنصوص واجهة LaRecipe بلغة الصفحة، ومساعد navigate(url).
request(path, options) طلب fetch إلى مسارات تطبيقك أنت. يرفض المصادر الأخرى، ويرسل ملف تعريف الارتباط الخاص بالجلسة، و XSRF-TOKEN الخاص بـ Laravel على شكل X-XSRF-TOKEN، و Accept: application/json، ويرمّز body بصيغة JSON، ويعيد { ok: true, status, data } أو { ok: false, status, data, errors, message }، مع أخطاء التحقق 422 من Laravel في errors.
Button، LinkButton، Input، Textarea، Field، Callout، Badge، Card، Kbd، Loader، Ticket، Perforation الأجزاء التي بُنيت منها الواجهة المدمجة، في المظهرين ومن اليمين إلى اليسار. ويأخذ Button الخاصية variant (primary و secondary و outline و ghost و ink)، و size، و loading.
Icons، Icon مجموعة الأيقونات الخاصة بالواجهة.
Slot، useSlotDefault() تستبدل منطقة مدمجة في الصفحة مع إمكانية تصيير الأصلية. راجع نقاط الإدراج والفتحات.
defineExtension() تسجّل مكوّناتك ونقاط الإدراج واستبدالات الفتحات في الصفحة.
شارك المستخدم المسجّل وقيم الصفحة

لا يُشارَك المستخدم المسجّل إلا حين تطلب ذلك، حقلًا حقلًا:

app/Providers/AppServiceProvider.php
LaRecipe::shareUser(fn ($user) => ['name' => $user->name]);

والقيم التي يحتاج إليها مكوّن من صفحة بعينها توضع تحت meta: في ترويسة تلك الصفحة، وتصل على شكل page.meta:

yaml
---
title: Pricing
meta:
  plan: enterprise
---

meta هي الترويسة الوحيدة خارج مفاتيح LaRecipe التي تصل إلى المتصفح. أما المفاتيح المجهولة الأخرى فتبقى على الخادم، لأنها غالبًا تحمل ملاحظات للفريق، مثل المسؤول عن الصفحة أو رقم تذكرة. راجع ترويسة الصفحة.

ملف دخول الامتداد

ملف دخول واحد يسجّل كل المكوّنات المخصّصة في تطبيقك:

resources/larecipe/extension.tsx
import { defineExtension } from '@larecipe/ui'
import { DocsFlow } from './DocsFlow'
import { InstallTabs } from './InstallTabs'
import { Subscribe } from './Subscribe'
import './docs-flow.css'
import './install-tabs.css'
import './subscribe.css'
// Server-only components (no React): only their styles ship here.
import './byline.css'
import './featured-on.css'
import './features.css'

// Our LaRecipe extension: the React side of the components this site adds.
// Each one is also defined in PHP (AppServiceProvider) with its props, its
// server fallback and its Markdown form.
export default defineExtension({ components: { DocsFlow, InstallTabs, Subscribe } })

يقرأ CSS الخاص به متغيرات المظهر في LaRecipe، فيتبع النموذج لون الفريق المميِّز والوضع الداكن والصفحات من اليمين إلى اليسار من دون قواعد إضافية. والقواعد نفسها تنسّق نموذج React والبديل بصيغة HTML، لأن كليهما يستخدم أسماء الأصناف نفسها.

resources/larecipe/subscribe.css
resources/larecipe/subscribe.css
/*
 * The newsletter form. It reads LaRecipe's theme tokens, so it follows the
 * site's accent, dark mode and direction. The same rules style the server
 * fallback (no JavaScript) and the React form.
 */
.lr-subscribe-wrap {
  margin-block: 40px 8px;
}

.lr-subscribe {
  display: grid;
  gap: 20px;
  padding: 24px;
  border: 1px solid color-mix(in srgb, var(--lr-accent-current) 20%, var(--lr-hairline));
  border-radius: var(--lr-radius-lg);
  background:
    radial-gradient(120% 160% at 100% 0%, color-mix(in srgb, var(--lr-accent-current) 9%, transparent), transparent 62%),
    var(--lr-paper);
  box-shadow: var(--lr-shadow-xs);
}

[dir='rtl'] .lr-subscribe {
  background:
    radial-gradient(120% 160% at 0% 0%, color-mix(in srgb, var(--lr-accent-current) 9%, transparent), transparent 62%),
    var(--lr-paper);
}

.lr-prose .lr-subscribe p,
.lr-subscribe p {
  margin: 0;
}

.lr-subscribe-head {
  display: flex;
  align-items: flex-start;
  gap: 14px;
}

.lr-subscribe-icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  border-radius: var(--lr-radius-md);
  background: var(--lr-accent-soft);
  color: var(--lr-accent-current);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--lr-accent-current) 18%, transparent);
}

.lr-subscribe-copy {
  display: grid;
  gap: 4px;
  min-width: 0;
}

.lr-subscribe-label {
  font-family: var(--lr-font-mono);
  font-size: 11px;
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--lr-accent-current);
}

.lr-subscribe .lr-subscribe-title {
  margin-block-start: 2px;
  font-size: 17px;
  font-weight: 600;
  line-height: 1.35;
  letter-spacing: -0.01em;
  color: var(--lr-ink);
}

.lr-subscribe .lr-subscribe-body {
  font-size: 14px;
  line-height: 1.5;
  color: var(--lr-ink-muted);
}

/* The field and the button share one bordered row, focused as one. */
.lr-subscribe-row {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px;
  border: 1px solid var(--lr-hairline);
  border-radius: calc(var(--lr-radius-md) + 4px);
  background: var(--lr-surface);
  transition: border-color var(--lr-duration-hover) var(--lr-ease), box-shadow var(--lr-duration-hover) var(--lr-ease);
}

.lr-subscribe-row:hover {
  border-color: var(--lr-hairline-strong);
}

.lr-subscribe-row:focus-within {
  border-color: var(--lr-accent-current);
  box-shadow: 0 0 0 3px var(--lr-accent-ring);
}

.lr-subscribe-field {
  flex: 1 1 auto;
  display: flex;
  min-width: 0;
}

.lr-subscribe-field .lr-input,
.lr-subscribe-field .lr-input:hover,
.lr-subscribe-field .lr-input:focus,
.lr-subscribe-field .lr-input:focus-visible {
  width: 100%;
  border-color: transparent;
  background: transparent;
  outline: none;
  box-shadow: none;
}

.lr-subscribe-row .lr-button {
  flex: none;
}

.lr-subscribe .lr-subscribe-error {
  font-size: 14px;
  line-height: 1.5;
  color: var(--lr-danger);
}

/* The quiet line under the form on the Introduction (showcase). */
.lr-prose .lr-subscribe-note,
.lr-subscribe-note {
  margin: 10px 4px 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--lr-ink-muted);
}

.lr-prose .lr-subscribe-note a,
.lr-subscribe-note a {
  color: inherit;
  text-decoration: underline;
  text-decoration-color: var(--lr-hairline-strong);
  text-underline-offset: 3px;
}

.lr-prose .lr-subscribe-note a:hover,
.lr-subscribe-note a:hover {
  color: var(--lr-ink);
  text-decoration-color: currentColor;
}

:root:lang(ar) .lr-subscribe-label {
  font-family: var(--lr-font-sans);
  font-size: 12.5px;
  letter-spacing: normal;
  text-transform: none;
}

التعريف في PHP

كل مكوّن مخصّص يحتاج إلى تعريف في PHP، عادةً في AppServiceProvider::boot(). يسمّي التعريف المكوّن، ويصف خصائصه، ويعطي بديله بصيغة HTML وصيغة Markdown الخاصة به:

app/Providers/AppServiceProvider.php
LaRecipe::component('Subscribe')
    ->description('A sign-up form for the LaRecipe newsletter.')
    ->props([
        'list' => ['type' => 'string', 'default' => 'larecipe', 'description' => 'The newsletter list to join'],
        'showcase' => ['type' => 'boolean', 'default' => false, 'description' => 'Add a faded line under the form that links to how it is built'],
    ])
    ->fallback(function (array $props, string $children, RenderedComponent $component) {
        $locale = in_array($component->locale, SubscribeController::LOCALES, true) ? $component->locale : 'en';

        return view('docs.subscribe', [
            'list' => $props['list'],
            'locale' => $locale,
            'showcase' => (bool) $props['showcase'],
            'guide' => url(($locale === 'ar' ? 'ar/' : '').($component->version ?? '3.x').'/customize/custom-components'),
        ]);
    })
    ->markdown(function (array $props, string $children, RenderedComponent $component) {
        $locale = in_array($component->locale, SubscribeController::LOCALES, true) ? $component->locale : 'en';
        $line = __('newsletter.markdown', ['url' => url('/newsletter')], $locale);

        if (! $props['showcase']) {
            return $line;
        }

        return $line."\n\n".__('newsletter.note', [], $locale).' '.__('newsletter.note_link', [], $locale).': '
            .url(($locale === 'ar' ? 'ar/' : '').($component->version ?? '3.x').'/customize/custom-components');
    })
    ->island();

تصف props() كل خاصية، وتعيد fallback() صيغة HTML من الخادم، وتعيد markdown() ما يقرؤه الوكلاء، وتسمح island() لـ React بأن يُركَّب فوق البديل. والوسم الذي لا تعريف له في PHP مكوّن مجهول: يُصيَّر نصًا مُهرَّبًا، ويبلّغ عنه php artisan larecipe:validate بالقاعدة components.unknown. فلا تستطيع الصفحة أبدًا أن تطلب من المتصفح تشغيل كود لم يسجّله تطبيقك.

كل دوال التعريف
الدالة ما تضبطه
props([...]) type لكل خاصية، واختياريًا default و required و enum و description (أو الصيغة المختصرة 'list' => 'string'). ويفحصها larecipe:validate وكل عملية تصيير: فالخاصية المطلوبة الناقصة أو القيمة الواقعة خارج enum خطأ من نوع components.props. وفي مجموعة المستودع، تصل إلى React الخصائص التي لا تصفها أيضًا. أما في مجموعة قاعدة البيانات، التي قد يكون كتّابها أقل موثوقية من قاعدة الكود لديك، فتُحذف، لذا صِف كل خاصية تقرؤها.
fallback(fn (array $props, string $children, RenderedComponent $context) => ...) صيغة HTML من الخادم. أعد نصًا، أو view، أو أي شيء Htmlable. و $children هو Markdown المُصيَّر داخل الوسم، و $context يحمل المجموعة والإصدار واللغة ومسار الصفحة.
markdown(fn (array $props, string $children, $context) => ...) السطر أو الكتلة التي يقرؤها الوكلاء. ومن دونها، يحصل الوكلاء على المحتوى الداخلي.
island() تغلّف البديل في عنصر نائب يُركَّب فيه React. ومن دونها، يبقى الوسم المضمَّن في النص على صيغة HTML البديلة ولا يعمل React أبدًا. وفي نقاط الإدراج، تُغلَّف المكوّنات دائمًا.
raw() تُبقي المحتوى الواقع بين الوسمين نصًا حرفيًا بدلًا من Markdown.
description('...') جملة واحدة لمرجع المكوّنات المولَّد الذي يقرؤه الوكلاء.

البديل

البديل هو ما يحصل عليه القارئ بلا JavaScript، فاجعله يعمل وحده. وبديل Subscribe نموذج عادي يرسل إلى المسار نفسه:

resources/views/docs/subscribe.blade.php
{{--
    The Subscribe component without JavaScript: a plain form that posts to
    /newsletter/subscribe and lands on /newsletter with the outcome. Its markup
    matches resources/larecipe/Subscribe.tsx, so React mounts without a shift,
    and it speaks the page's language ($locale: en or ar).
    The page is cached and shared, so the CSRF token comes from csrfField(),
    which LaRecipe fills in on every response.
--}}
<div class="lr-subscribe-wrap">
    <form class="lr-subscribe" method="post" action="/newsletter/subscribe" data-lr-subscribe="html">
        {!! \LaRecipe\Facades\LaRecipe::csrfField() !!}
        <input type="hidden" name="list" value="{{ $list }}">
        <input type="hidden" name="locale" value="{{ $locale }}">
        <div class="lr-subscribe-head">
            <span class="lr-subscribe-icon" aria-hidden="true">@include('larecipe::partials.icon', ['name' => 'Mail', 'iconSize' => 18, 'iconClass' => ''])</span>
            <div class="lr-subscribe-copy">
                <p class="lr-subscribe-label">{{ __('newsletter.label', [], $locale) }}</p>
                <p class="lr-subscribe-title">{{ __('newsletter.title', [], $locale) }}</p>
                <p class="lr-subscribe-body">{{ __('newsletter.body', [], $locale) }}</p>
            </div>
        </div>
        <div class="lr-subscribe-row">
            <label class="lr-subscribe-field">
                <span class="lr-sr">{{ __('newsletter.email', [], $locale) }}</span>
                <input class="lr-input" type="email" name="email" required autocomplete="email" placeholder="you@example.com" dir="ltr">
            </label>
            <button class="lr-button lr-button-primary" type="submit">{{ __('newsletter.submit', [], $locale) }}</button>
        </div>
    </form>
    @if ($showcase)
        <p class="lr-subscribe-note">{{ __('newsletter.note', [], $locale) }} <a href="{{ $guide }}">{{ __('newsletter.note_link', [], $locale) }}</a></p>
    @endif
</div>

المقالات المُصيَّرة تُخزَّن مؤقتًا ويتشاركها القرّاء، لذا لا يجوز أن يثبّت البديل داخله أي شيء يخصّ طلبًا واحدًا. ويطبع LaRecipe::csrfField() عنصرًا نائبًا يملؤه LaRecipe برمز CSRF الخاص بالقارئ في كل استجابة. ومن دون JavaScript، يرسل النموذج وينتهي في صفحة النشرة البريدية، وهي صفحة عادية تُظهر هل نجح الاشتراك.

صيغة Markdown

لا ينبغي للوكلاء أن يملؤوا النماذج، لذا فصيغة Markdown الخاصة بـ Subscribe سطر واحد فيه رابط، ومعه السطر الباهت حين تُضبط الخاصية showcase. اقرأ المقدمة بصيغة Markdown وستجده قرب النهاية:

markdown
LaRecipe release notes by email. Unsubscribe any time. Subscribe with your email address: https://larecipe.saleem.dev/newsletter

This form is a custom React component this site adds to LaRecipe. See how it is built: https://larecipe.saleem.dev/3.x/customize/custom-components
بلغة القارئ

يستطيع المكوّن أن يتبع لغة الصفحة كما تفعل الواجهة المدمجة. فـ Subscribe يقرأ locale من useLaRecipe() في React، و $context->locale في بديله، ثم يختار كلماته من lang/en/newsletter.php أو lang/ar/newsletter.php. وفي المقدمة العربية يُقرأ النموذج نفسه من اليمين إلى اليسار، بالعربية، ويجيب المسار بالعربية أيضًا.

المسار الذي يرسل إليه النموذج

يرسل نموذج React والبديل كلاهما إلى مسار عادي في تطبيقك، مع الـ middleware الخاص بك. وهذا هو ذلك المسار، مقروءًا من التطبيق العامل:

Route POST /newsletter/subscribe
MethodPOST
URI/newsletter/subscribe
Middlewareweb, throttle

يتحقق الـ controller من العنوان ويرسله إلى Nashra. ولا يفشل أبدًا بخطأ 500: فمفتاح API الناقص أو الخدمة التي يتعذّر الوصول إليها يعودان بجملة واحدة لطيفة، بصيغة JSON لـ request()، وعلى شكل إعادة توجيه للنموذج العادي.

app/Http/Controllers/SubscribeController.php
<?php

namespace App\Http\Controllers;

use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Nashra\Sdk\Exceptions\AuthenticationException;
use Nashra\Sdk\Exceptions\AuthorizationException;
use Nashra\Sdk\Exceptions\RateLimitException;
use Nashra\Sdk\Exceptions\ValidationException;
use Nashra\Sdk\Facades\Nashra;
use Throwable;

/**
 * POST /newsletter/subscribe: adds a reader to the LaRecipe newsletter on Nashra.
 *
 * Called by the Subscribe component in two ways: as JSON from the React form
 * (through request() from @larecipe/ui), and as a plain form post from the
 * server fallback when JavaScript is off. A plain post lands on /newsletter,
 * which can show the outcome; the docs page itself is cached HTML and cannot.
 *
 * It answers in the page's language (English or Arabic), and it never fails
 * with a 500: a missing SDK or API key, an unreachable API or a rejected
 * address each come back as one plain sentence.
 */
final class SubscribeController
{
    public const LOCALES = ['en', 'ar'];

    public function __invoke(Request $request): JsonResponse|RedirectResponse
    {
        $locale = in_array($request->input('locale'), self::LOCALES, true) ? $request->input('locale') : 'en';

        $data = $request->validate([
            'email' => ['required', 'string', 'email', 'max:254'],
            'list' => ['nullable', 'string', 'max:64'],
            'source' => ['nullable', 'string', 'max:255'],
            'locale' => ['nullable', 'string'],
        ], [
            'email.required' => __('newsletter.required', [], $locale),
            'email.email' => __('newsletter.invalid', [], $locale),
        ]);

        [$subscribed, $message, $field] = $this->subscribe($data['email'], $locale);

        if ($request->expectsJson()) {
            return $subscribed
                ? response()->json(['subscribed' => true])
                : response()->json([
                    'subscribed' => false,
                    'message' => $message,
                    'errors' => $field ? [$field => [$message]] : null,
                ], $field ? 422 : 503);
        }

        $page = redirect()->route('newsletter', $locale === 'en' ? [] : ['lang' => $locale]);

        return $subscribed
            ? $page->with('subscribed', $data['email'])
            : $page->withInput()->withErrors(['email' => $message]);
    }

    /** @return array{bool, ?string, ?string} subscribed, a message for the reader, the field it is about */
    private function subscribe(string $email, string $locale): array
    {
        if (! class_exists(Nashra::class) || blank(config('nashra.api_key'))) {
            return [false, __('newsletter.not_connected', [], $locale), null];
        }

        try {
            Nashra::subscribers()->create(['email' => $email]);

            return [true, null, null];
        } catch (ValidationException $exception) {
            return [false, $exception->errors()['email'][0] ?? __('newsletter.rejected', [], $locale), 'email'];
        } catch (RateLimitException) {
            return [false, __('newsletter.rate_limited', [], $locale), null];
        } catch (AuthenticationException|AuthorizationException $exception) {
            report($exception);

            return [false, __('newsletter.not_connected', [], $locale), null];
        } catch (Throwable $exception) {
            report($exception);

            return [false, __('newsletter.unavailable', [], $locale), null];
        }
    }
}

ابنِه

المكوّنات المخصّصة هي الجزء الوحيد من LaRecipe الذي يحتاج إلى Node، ووقت البناء فقط، على جهازك أو في CI. أضف @larecipe/vite و @larecipe/ui اعتمادياتٍ للتطوير، ثم أضف الإضافة إلى إعدادات Vite لديك. ويعمل @larecipe/vite مع Vite 6 و 7 و 8. وهذه إعدادات هذا الموقع نفسه:

vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import tailwindcss from '@tailwindcss/vite';
import larecipe from '@larecipe/vite';

export default defineConfig({
    plugins: [
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
        tailwindcss(),
        // Builds our LaRecipe extension (the React side of the Subscribe component)
        // into public/build/larecipe/, which LaRecipe loads on every docs page.
        larecipe({ input: 'resources/larecipe/extension.tsx' }),
    ],
    server: {
        watch: {
            ignored: ['**/storage/framework/views/**'],
        },
    },
});

ثم يكتب npm run build الامتداد في public/build/larecipe/. ولا يشغّل خادمك Node أبدًا: فالملفات المبنية ثابتة. ويبحث LaRecipe عن public/build/larecipe/manifest.json في كل صفحة توثيق، وحين يجد مدخل الامتداد فيه، يضيف ملف أنماطه ووحدته بعد بيئة التشغيل الخاصة به مباشرة. فلا وسم script تضيفه بيدك.

نسخة واحدة من React في الصفحة

البناء وحدة ES مع ملف manifest. وتُستبعد react و react-dom و @larecipe/ui من الحزمة عن قصد. فكل صفحة توثيق تطبع import map قبل سكربتات LaRecipe، يوجّه الأسماء المجرّدة react و react/jsx-runtime و react-dom و react-dom/client و @larecipe/ui إلى الملفات المبنية الخاصة بـ LaRecipe:

html
<script type="importmap">{"imports":{"react":"/larecipe-assets/{build}/assets/react-….js","@larecipe/ui":"/larecipe-assets/{build}/assets/larecipe-ui-….js", "…": "…"}}</script>

يُبقي امتدادك هذه الاستيرادات مجرّدة، فيحلّها المتصفح إلى الوحدات نفسها التي يستخدمها LaRecipe. فتوجد نسخة واحدة من React وسجلّ واحد للمكوّنات، وتعمل الـ hooks مثل useLaRecipe() داخل مكوّناتك. وتعمل defineExtension() سواء حُمّلت وحدتك قبل بيئة تشغيل LaRecipe أو بعدها.

استخدمه

داخل النص، في Markdown. اكتب الوسم في سطر مستقل، مع أسطر فارغة حوله، كأي مكوّن مدمج. والخصائص نصوص:

markdown
<Subscribe list="larecipe" />

في كل صفحة. ضعه في نقطة إدراج في larecipe.json، كما يفعل هذا الموقع بـ "inserts": { "article.after": ["Subscribe"] }. راجع نقاط الإدراج والفتحات.

القارئ يحصل على
متصفح مع JavaScript نموذج React، الذي يرسل بـ request() ويجيب في مكانه
متصفح بلا JavaScript، أو محرك زحف النموذج البديل، الذي يرسل وينتهي في /newsletter
وكيل يقرأ .md أو MCP سطر واحد: رابط النشرة البريدية

التالي

النشرة البريدية

ابقَ على اطلاع

ملاحظات إصدارات LaRecipe عبر البريد الإلكتروني. يمكنك إلغاء الاشتراك في أي وقت.