@drhomet/mithaq 0.1.1 → 0.2.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.
Files changed (120) hide show
  1. package/LICENSES/openstatus-time-picker.txt +21 -0
  2. package/README.md +161 -0
  3. package/dist/asas/build.d.ts +10 -2
  4. package/dist/asas/build.js +69 -22
  5. package/dist/asas/cli/mithaq.js +35 -3
  6. package/dist/asas/cli/upgrade.d.ts +22 -9
  7. package/dist/asas/cli/upgrade.js +187 -78
  8. package/dist/asas/http/fetch.d.ts +21 -3
  9. package/dist/asas/http/fetch.js +123 -60
  10. package/dist/asas/http/orpc.d.ts +5 -3
  11. package/dist/asas/http/orpc.js +126 -108
  12. package/dist/asas/runner/bulk.d.ts +1 -1
  13. package/dist/asas/runner/bulk.js +16 -11
  14. package/dist/asas/runner/errors.d.ts +5 -2
  15. package/dist/asas/runner/errors.js +18 -2
  16. package/dist/asas/runner/read.d.ts +6 -2
  17. package/dist/asas/runner/read.js +28 -6
  18. package/dist/asas/runner/steps.d.ts +22 -4
  19. package/dist/asas/runner/steps.js +15 -9
  20. package/dist/asas/runner/store.d.ts +4 -0
  21. package/dist/asas/runner/store.js +21 -0
  22. package/dist/asas/runner/tenant.d.ts +2 -2
  23. package/dist/asas/runner/tenant.js +78 -16
  24. package/dist/asas/runner/write.d.ts +173 -11
  25. package/dist/asas/runner/write.js +274 -46
  26. package/dist/auth/better-auth/access.d.ts +14 -0
  27. package/dist/auth/better-auth/access.js +32 -0
  28. package/dist/auth/better-auth/generate.d.ts +3 -1
  29. package/dist/auth/better-auth/generate.js +26 -8
  30. package/dist/auth/better-auth/index.d.ts +2 -0
  31. package/dist/auth/better-auth/tenant.d.ts +11 -3
  32. package/dist/auth/better-auth/tenant.js +10 -2
  33. package/dist/auth/local/index.js +5 -3
  34. package/dist/base/fields.d.ts +3 -2
  35. package/dist/base/fields.js +3 -2
  36. package/dist/base/index.d.ts +2 -1
  37. package/dist/base/json-schema.d.ts +7 -0
  38. package/dist/base/json-schema.js +35 -0
  39. package/dist/base/kinds.d.ts +21 -0
  40. package/dist/base/kinds.js +46 -1
  41. package/dist/base/rules.d.ts +4 -4
  42. package/dist/base/rules.js +4 -3
  43. package/dist/drizzle/index.d.ts +9 -139
  44. package/dist/drizzle/index.js +143 -24
  45. package/dist/drizzle/kinds.d.ts +1 -0
  46. package/dist/drizzle/kinds.js +3 -1
  47. package/dist/drizzle/migrate.d.ts +26 -0
  48. package/dist/drizzle/migrate.js +69 -26
  49. package/dist/drizzle/rules.d.ts +1 -1
  50. package/dist/index.d.ts +11 -21
  51. package/dist/index.js +8 -18
  52. package/dist/mithaq/define.d.ts +62 -15
  53. package/dist/mithaq/define.js +23 -9
  54. package/dist/mithaq/generate-messages.js +5 -4
  55. package/dist/mithaq/language.d.ts +43 -4
  56. package/dist/mithaq/message-file.d.ts +13 -0
  57. package/dist/mithaq/message-file.js +29 -0
  58. package/dist/mithaq/messages.js +5 -0
  59. package/dist/mithaq/resolve.js +70 -10
  60. package/dist/mithaq/types.d.ts +29 -1
  61. package/dist/rbac/index.d.ts +2 -2
  62. package/dist/rbac/index.js +1 -1
  63. package/dist/rich-text/index.d.ts +1 -0
  64. package/dist/rich-text/index.js +1 -0
  65. package/dist/rich-text/rich-text.d.ts +2 -55
  66. package/dist/rich-text/rich-text.js +2 -125
  67. package/dist/rich-text/value.d.ts +55 -0
  68. package/dist/rich-text/value.js +126 -0
  69. package/dist/screen/client.d.ts +5 -2
  70. package/dist/screen/client.js +5 -2
  71. package/dist/screen/generate.js +48 -6
  72. package/dist/screen/index.d.ts +18 -1
  73. package/dist/screen/index.js +24 -6
  74. package/dist/screen/react.d.ts +2 -0
  75. package/dist/storage/index.d.ts +2 -3
  76. package/dist/storage/index.js +1 -2
  77. package/dist/storage/storage.d.ts +2 -0
  78. package/dist/storage/storage.js +9 -2
  79. package/dist/targets.d.ts +14 -0
  80. package/dist/targets.js +16 -0
  81. package/dist/vite.d.ts +7 -0
  82. package/dist/vite.js +81 -0
  83. package/dist/zod/index.d.ts +12 -3
  84. package/dist/zod/index.js +6 -5
  85. package/dist/zod/kinds.d.ts +3 -1
  86. package/dist/zod/kinds.js +11 -2
  87. package/dist/zod/rules.d.ts +1 -1
  88. package/docs/packages/mithaq/README.md +962 -0
  89. package/docs/packages/mithaq/src/asas/cli/README.md +252 -0
  90. package/docs/packages/mithaq/src/asas/runner/README.md +472 -0
  91. package/docs/packages/mithaq/src/drizzle/README.md +39 -0
  92. package/docs/packages/mithaq-admin/README.md +312 -0
  93. package/docs/templates/asas/README.md +1056 -0
  94. package/package.json +37 -8
  95. package/skills/add-field/SKILL.md +27 -0
  96. package/skills/add-kayan/SKILL.md +26 -0
  97. package/skills/eject-admin-piece/SKILL.md +28 -0
  98. package/skills/upgrade-mithaq/SKILL.md +35 -0
  99. package/r/mithaq-cells.json +0 -20
  100. package/r/mithaq-controls.json +0 -37
  101. package/r/mithaq-directional-icon.json +0 -15
  102. package/r/mithaq-expected-updated-at.json +0 -15
  103. package/r/mithaq-field-shell.json +0 -18
  104. package/r/mithaq-filter-operators.json +0 -15
  105. package/r/mithaq-form.json +0 -33
  106. package/r/mithaq-list-columns.json +0 -23
  107. package/r/mithaq-list-filters.json +0 -24
  108. package/r/mithaq-list-pagination.json +0 -24
  109. package/r/mithaq-list-query.json +0 -18
  110. package/r/mithaq-list-selection.json +0 -19
  111. package/r/mithaq-list.json +0 -33
  112. package/r/mithaq-nav.json +0 -24
  113. package/r/mithaq-nodes.json +0 -34
  114. package/r/mithaq-relationship-cell.json +0 -20
  115. package/r/mithaq-relationship-control.json +0 -20
  116. package/r/mithaq-rich-text.json +0 -40
  117. package/r/mithaq-screens-host.json +0 -96
  118. package/r/mithaq-storage.json +0 -44
  119. package/r/mithaq-upload.json +0 -29
  120. package/r/registry.json +0 -550
@@ -1,40 +0,0 @@
1
- {
2
- "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
- "name": "mithaq-rich-text",
4
- "title": "Mithaq rich text",
5
- "description": "The richText kind's control and read view: a Lexical editor with history, block formats, text formats, links, alignment, a divider and the / menu, written in the page's direction, and its viewer, both over the nodes and picks a screen provides, and both drawing a stored value whose node they cannot mount as its words, read-only, with the reason in the viewer's language; and the node list itself, which the server reads with too.",
6
- "dependencies": [
7
- "lexical@0.52.0",
8
- "@lexical/extension@0.52.0",
9
- "@lexical/link@0.52.0",
10
- "@lexical/list@0.52.0",
11
- "@lexical/react@0.52.0",
12
- "@lexical/rich-text@0.52.0",
13
- "@lexical/selection@0.52.0",
14
- "@lexical/utils@0.52.0"
15
- ],
16
- "registryDependencies": [
17
- "button",
18
- "button-group",
19
- "direction",
20
- "dropdown-menu",
21
- "input",
22
- "toggle",
23
- "@mithaq/mithaq-controls"
24
- ],
25
- "files": [
26
- {
27
- "path": "registry/rich-text.tsx",
28
- "content": "import { createContext, use, useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type JSX, type KeyboardEvent as ReactKeyboardEvent, type ReactElement, type ReactNode } from 'react'\nimport { createPortal } from 'react-dom'\nimport { LexicalComposer } from '@lexical/react/LexicalComposer'\nimport { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'\nimport { ContentEditable } from '@lexical/react/LexicalContentEditable'\nimport { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'\nimport { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'\nimport { LinkPlugin } from '@lexical/react/LexicalLinkPlugin'\nimport { ListPlugin } from '@lexical/react/LexicalListPlugin'\nimport { CheckListPlugin } from '@lexical/react/LexicalCheckListPlugin'\nimport { HorizontalRulePlugin } from '@lexical/react/LexicalHorizontalRulePlugin'\nimport { HorizontalRuleNode as HorizontalRuleView, INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode'\nimport { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'\nimport { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'\nimport { LexicalTypeaheadMenuPlugin, MenuOption, useBasicTypeaheadTriggerMatch } from '@lexical/react/LexicalTypeaheadMenuPlugin'\nimport { $isLinkNode, $toggleLink, TOGGLE_LINK_COMMAND } from '@lexical/link'\nimport { Input } from '@/components/ui/input'\nimport { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, ListNode } from '@lexical/list'\nimport { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode, type HeadingTagType } from '@lexical/rich-text'\nimport { $isAtNodeEnd, $setBlocksType } from '@lexical/selection'\nimport { $dfs, $findMatchingParent, $getNearestBlockElementAncestorOrThrow, $getNearestNodeOfType, mergeRegister } from '@lexical/utils'\nimport {\n $createParagraphNode,\n $getRoot,\n createEditor,\n $getSelection,\n $isElementNode,\n $isLineBreakNode,\n $isNodeSelection,\n $isRangeSelection,\n $isTextNode,\n BLUR_COMMAND,\n CAN_REDO_COMMAND,\n CAN_UNDO_COMMAND,\n COMMAND_PRIORITY_CRITICAL,\n COMMAND_PRIORITY_HIGH,\n COMMAND_PRIORITY_LOW,\n FORMAT_ELEMENT_COMMAND,\n FORMAT_TEXT_COMMAND,\n HISTORY_MERGE_TAG,\n INDENT_CONTENT_COMMAND,\n KEY_ESCAPE_COMMAND,\n OUTDENT_CONTENT_COMMAND,\n REDO_COMMAND,\n SELECTION_CHANGE_COMMAND,\n UNDO_COMMAND,\n type BaseSelection,\n type ElementFormatType,\n type EditorState,\n type ElementNode,\n type Klass,\n type LexicalEditor,\n type LexicalNode,\n type RangeSelection,\n type TextFormatType,\n type TextNode,\n} from 'lexical'\nimport {\n CheckIcon,\n PencilIcon,\n TrashIcon,\n XIcon,\n AlignCenterIcon,\n AlignJustifyIcon,\n AlignLeftIcon,\n AlignRightIcon,\n BoldIcon,\n ChevronDownIcon,\n CodeIcon,\n EraserIcon,\n Heading1Icon,\n Heading2Icon,\n Heading3Icon,\n IndentDecreaseIcon,\n IndentIncreaseIcon,\n ItalicIcon,\n LinkIcon,\n ListIcon,\n ListOrderedIcon,\n ListTodoIcon,\n MinusIcon,\n QuoteIcon,\n RedoIcon,\n StrikethroughIcon,\n TextIcon,\n UnderlineIcon,\n UndoIcon,\n} from 'lucide-react'\nimport { useDirection } from '@/components/ui/direction'\nimport { Button } from '@/components/ui/button'\nimport { ButtonGroup } from '@/components/ui/button-group'\nimport { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'\nimport { Toggle } from '@/components/ui/toggle'\nimport { cn } from '@/lib/utils'\nimport { ariaRequired, changeOf, type ControlProps, type Translate } from './controls.tsx'\nimport { richTextBounds, safeUrl, typesOf, unknownType } from 'mithaq/rich-text'\nimport { defaultNodes } from './rich-text-nodes.ts'\n\n/** One node of Lexical's serialized editor state. */\nexport type RichTextNode = { type: string; children?: RichTextNode[]; text?: string; [key: string]: unknown }\n/** What a `richText` field holds. */\nexport type RichTextValue = { root: RichTextNode & { type: 'root'; children: RichTextNode[] }; [key: string]: unknown }\nexport type RichTextDirection = 'ltr' | 'rtl'\n\n/** One empty paragraph, with no direction of its own so the block takes the one its first letter gives it. */\nconst emptyValue = (): RichTextValue => ({\n root: {\n children: [{ children: [], direction: null, format: '', indent: 0, textFormat: 0, textStyle: '', type: 'paragraph', version: 1 }],\n direction: null,\n format: '',\n indent: 0,\n type: 'root',\n version: 1,\n },\n})\n\nconst valueOf = (value: unknown): RichTextValue => {\n const held = value && typeof value === 'object' && (value as RichTextValue).root ? (value as RichTextValue) : undefined\n return held?.root.children?.length ? held : emptyValue()\n}\n\n/** Whether the editor holds nothing at all: only empty paragraphs, which the control reports as `null`. */\nconst blank = (value: RichTextValue) => value.root.children.every((node) => node.type === 'paragraph' && !node.children?.length)\n\nconst theme = {\n paragraph: 'mb-2',\n heading: {\n h1: 'mb-3 text-2xl font-semibold',\n h2: 'mb-3 text-xl font-semibold',\n h3: 'mb-2 text-lg font-semibold',\n },\n list: {\n nested: { listitem: 'list-none before:hidden after:hidden' },\n ol: 'ms-6 list-decimal',\n ul: 'ms-6 list-disc',\n checklist: 'ms-2 list-none',\n listitem: 'mb-1',\n listitemChecked:\n 'relative mb-1 list-none px-7 line-through opacity-70 before:absolute before:start-1 before:top-1 before:size-4 before:rounded before:border before:border-primary before:bg-primary after:absolute after:start-[9px] after:top-[5px] after:block after:h-2 after:w-1 after:rotate-45 after:border-b-2 after:border-e-2 after:border-white after:content-[\"\"]',\n listitemUnchecked:\n 'relative mb-1 list-none px-7 before:absolute before:start-1 before:top-1 before:size-4 before:rounded before:border before:border-primary before:content-[\"\"]',\n },\n quote: 'mb-2 border-s-2 ps-3 text-muted-foreground',\n link: 'text-primary underline underline-offset-4',\n hr: 'my-3 border-t',\n text: {\n bold: 'font-semibold',\n italic: 'italic',\n underline: 'underline underline-offset-2',\n strikethrough: 'line-through',\n underlineStrikethrough: 'underline line-through',\n code: 'rounded bg-muted px-1 font-mono text-[0.9em]',\n },\n}\n\n/** What a toolbar or a slash entry runs with: the editor, and whether the entry is the block the selection is in. */\ntype Selected = { editor: LexicalEditor; isActive: boolean }\n\n/**\n * One entry of the `/` menu, and of the block menu where `block: true`: the block type it makes,\n * its words in each locale, its icon, and what it does. A project's own node brings its own pick beside `defaultPicks`.\n */\nexport type RichTextPick = {\n key: string\n label: Readonly<Record<string, string>>\n Icon: ComponentType<{ className?: string }>\n keywords?: readonly string[]\n block?: true\n onSelect: (selected: Selected) => void\n}\n\nconst pickLabel = (pick: RichTextPick, locale: string) => pick.label[locale] ?? pick.label.en ?? pick.key\n\ntype Toolbar = { activeEditor: LexicalEditor; blockType: string; setBlockType: (blockType: string) => void }\n\n/** Runs `callback` on every selection change and once on mount. */\nfunction useSelectionChange(activeEditor: LexicalEditor, callback: (selection: BaseSelection) => void) {\n const latest = useRef(callback)\n latest.current = callback\n useEffect(\n () =>\n activeEditor.registerCommand(\n SELECTION_CHANGE_COMMAND,\n () => {\n const selection = $getSelection()\n if (selection) latest.current(selection)\n return false\n },\n COMMAND_PRIORITY_CRITICAL,\n ),\n [activeEditor],\n )\n useEffect(() => {\n activeEditor.getEditorState().read(() => {\n const selection = $getSelection()\n if (selection) latest.current(selection)\n })\n }, [activeEditor])\n}\n\nfunction getSelectedNode(selection: RangeSelection): TextNode | ElementNode {\n const anchorNode = selection.anchor.getNode()\n const focusNode = selection.focus.getNode()\n if (anchorNode === focusNode) return anchorNode\n return selection.isBackward()\n ? ($isAtNodeEnd(selection.focus) ? anchorNode : focusNode)\n : ($isAtNodeEnd(selection.anchor) ? anchorNode : focusNode)\n}\n\nconst toParagraph = (editor: LexicalEditor) =>\n editor.update(() => {\n const selection = $getSelection()\n if ($isRangeSelection(selection)) $setBlocksType(selection, () => $createParagraphNode())\n })\n\nconst toHeading = (editor: LexicalEditor, tag: HeadingTagType) =>\n editor.update(() => {\n const selection = $getSelection()\n if ($isRangeSelection(selection)) $setBlocksType(selection, () => $createHeadingNode(tag))\n })\n\nconst listOf = (command: typeof INSERT_UNORDERED_LIST_COMMAND) => ({ editor, isActive }: Selected) =>\n isActive ? toParagraph(editor) : editor.dispatchCommand(command, undefined)\n\nfunction HistoryButtons({ activeEditor, t }: { activeEditor: LexicalEditor; t: Translate }) {\n const [canUndo, setCanUndo] = useState(false)\n const [canRedo, setCanRedo] = useState(false)\n useEffect(\n () =>\n mergeRegister(\n activeEditor.registerCommand<boolean>(CAN_UNDO_COMMAND, (payload) => (setCanUndo(payload), false), COMMAND_PRIORITY_CRITICAL),\n activeEditor.registerCommand<boolean>(CAN_REDO_COMMAND, (payload) => (setCanRedo(payload), false), COMMAND_PRIORITY_CRITICAL),\n ),\n [activeEditor],\n )\n const undo = t('admin.richText.undo', 'Undo') ?? 'Undo'\n const redo = t('admin.richText.redo', 'Redo') ?? 'Redo'\n return (\n <ButtonGroup>\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={undo} aria-label={undo} disabled={!canUndo} onClick={() => activeEditor.dispatchCommand(UNDO_COMMAND, undefined)}>\n <UndoIcon className=\"size-4 rtl:-scale-x-100\" />\n </Button>\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={redo} aria-label={redo} disabled={!canRedo} onClick={() => activeEditor.dispatchCommand(REDO_COMMAND, undefined)}>\n <RedoIcon className=\"size-4 rtl:-scale-x-100\" />\n </Button>\n </ButtonGroup>\n )\n}\n\nconst nearestBlock = (anchorNode: LexicalNode) =>\n $isElementNode(anchorNode) && !anchorNode.isInline() ? anchorNode : $findMatchingParent(anchorNode, (node) => $isElementNode(node) && !node.isInline())\n\nfunction BlockDropdown({ toolbar, t, locale, picks }: { toolbar: Toolbar; t: Translate; locale: string; picks: readonly RichTextPick[] }) {\n const { activeEditor, blockType, setBlockType } = toolbar\n useSelectionChange(activeEditor, (selection) => {\n if (!$isRangeSelection(selection)) return\n const anchorNode = selection.anchor.getNode()\n const element = nearestBlock(anchorNode) ?? anchorNode.getTopLevelElementOrThrow()\n if (activeEditor.getElementByKey(element.getKey()) === null) return\n if ($isListNode(element)) {\n const parent = $getNearestNodeOfType<ListNode>(anchorNode, ListNode)\n setBlockType((parent ?? element).getListType())\n } else {\n setBlockType($isHeadingNode(element) ? element.getTag() : element.getType())\n }\n })\n const blocks = picks.filter((pick) => pick.block)\n const current = blocks.find((entry) => entry.key === blockType) ?? blocks[0]\n if (!current) return null\n // The accessible name contains the visible words, so a voice control can say them to press the button.\n const name = (t('admin.richText.blockFormatCurrent', 'Block format: {block}') ?? 'Block format: {block}').replace('{block}', () => pickLabel(current, locale))\n return (\n <DropdownMenu>\n <DropdownMenuTrigger render={<Button type=\"button\" variant=\"outline\" size=\"sm\" className=\"gap-1 px-2 max-sm:h-11\" title={name} aria-label={name} />}>\n <current.Icon className=\"size-4\" />\n <span className=\"text-sm\">{pickLabel(current, locale)}</span>\n <ChevronDownIcon className=\"size-3\" />\n </DropdownMenuTrigger>\n <DropdownMenuContent>\n {blocks.map((pick) => (\n <DropdownMenuItem key={pick.key} onClick={() => pick.onSelect({ editor: activeEditor, isActive: pick.key === blockType })}>\n <div className=\"flex items-center gap-1 font-normal\">\n <pick.Icon className=\"size-4\" />\n {pickLabel(pick, locale)}\n </div>\n </DropdownMenuItem>\n ))}\n </DropdownMenuContent>\n </DropdownMenu>\n )\n}\n\nconst formats = [\n { format: 'bold', Icon: BoldIcon, label: (t: Translate) => t('admin.richText.bold', 'Bold') },\n { format: 'italic', Icon: ItalicIcon, label: (t: Translate) => t('admin.richText.italic', 'Italic') },\n { format: 'underline', Icon: UnderlineIcon, label: (t: Translate) => t('admin.richText.underline', 'Underline') },\n { format: 'strikethrough', Icon: StrikethroughIcon, label: (t: Translate) => t('admin.richText.strikethrough', 'Strikethrough') },\n { format: 'code', Icon: CodeIcon, label: (t: Translate) => t('admin.richText.code', 'Code') },\n] as const\nfunction FormatButtons({ t }: { t: Translate }) {\n const [editor] = useLexicalComposerContext()\n const [active, setActive] = useState<readonly string[]>([])\n useSelectionChange(editor, (selection) => {\n if ($isRangeSelection(selection)) setActive(formats.filter(({ format }) => selection.hasFormat(format)).map(({ format }) => format))\n })\n return (\n <ButtonGroup>\n {formats.map(({ format, Icon, label }) => {\n const name = label(t) ?? format\n return (\n <Toggle key={format} variant=\"outline\" size=\"sm\" className=\"max-sm:h-11 max-sm:min-w-11\" title={name} aria-label={name} pressed={active.includes(format)} onPressedChange={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, format as TextFormatType)}>\n <Icon className=\"size-4\" />\n </Toggle>\n )\n })}\n </ButtonGroup>\n )\n}\n\nfunction ClearFormatting({ t }: { t: Translate }) {\n const [editor] = useLexicalComposerContext()\n const clear = useCallback(() => {\n editor.update(() => {\n const selection = $getSelection()\n if (!$isRangeSelection(selection)) return\n const { anchor, focus } = selection\n const nodes = selection.getNodes()\n const extractedNodes = selection.extract()\n if (anchor.key === focus.key && anchor.offset === focus.offset) return\n nodes.forEach((node, idx) => {\n // The first and last node are split by the selection, so text outside\n // it keeps its format.\n if ($isTextNode(node)) {\n let textNode = node\n if (idx === 0 && anchor.offset !== 0) textNode = textNode.splitText(anchor.offset)[1] || textNode\n if (idx === nodes.length - 1) textNode = textNode.splitText(focus.offset)[0] || textNode\n const extractedTextNode = extractedNodes[0]\n if (nodes.length === 1 && $isTextNode(extractedTextNode)) textNode = extractedTextNode\n if (textNode.__style !== '') textNode.setStyle('')\n if (textNode.__format !== 0) {\n textNode.setFormat(0)\n $getNearestBlockElementAncestorOrThrow(textNode).setFormat('')\n }\n } else if ($isHeadingNode(node) || $isQuoteNode(node)) {\n node.replace($createParagraphNode(), true)\n }\n })\n })\n }, [editor])\n const name = t('admin.richText.clearFormatting', 'Clear formatting') ?? 'Clear formatting'\n return (\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={name} aria-label={name} onClick={clear}>\n <EraserIcon className=\"size-4\" />\n </Button>\n )\n}\n\nfunction LinkButton({ t, setLinkEditMode }: { t: Translate; setLinkEditMode: (editing: boolean) => void }) {\n const [editor] = useLexicalComposerContext()\n const [isLink, setIsLink] = useState(false)\n useSelectionChange(editor, (selection) => {\n if (!$isRangeSelection(selection)) return\n const node = getSelectedNode(selection)\n setIsLink($isLinkNode(node.getParent()) || $isLinkNode(node))\n })\n const name = isLink ? (t('admin.richText.removeLink', 'Remove link') ?? 'Remove link') : (t('admin.richText.addLink', 'Add link') ?? 'Add link')\n return (\n <Toggle variant=\"outline\" size=\"sm\" className=\"max-sm:h-11 max-sm:min-w-11\" title={name} aria-label={name} pressed={isLink} onPressedChange={() => {\n setLinkEditMode(!isLink)\n // `LinkPlugin`'s own check refuses the placeholder, so it is made past it; `dropPlaceholders` removes it once the link editor is no longer open on it.\n if (isLink) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)\n else editor.update(() => $toggleLink(newLink))\n }}>\n <LinkIcon className=\"size-4\" />\n </Toggle>\n )\n}\n\nconst alignments = [\n { format: 'left', Icon: AlignLeftIcon, label: (t: Translate) => t('admin.richText.alignLeft', 'Align left') },\n { format: 'center', Icon: AlignCenterIcon, label: (t: Translate) => t('admin.richText.alignCenter', 'Align center') },\n { format: 'right', Icon: AlignRightIcon, label: (t: Translate) => t('admin.richText.alignRight', 'Align right') },\n { format: 'justify', Icon: AlignJustifyIcon, label: (t: Translate) => t('admin.richText.justify', 'Justify') },\n] as const\nfunction AlignButtons({ t }: { t: Translate }) {\n const [editor] = useLexicalComposerContext()\n const [current, setCurrent] = useState<ElementFormatType>('')\n useSelectionChange(editor, (selection) => {\n if (!$isRangeSelection(selection)) return\n const node = getSelectedNode(selection)\n const parent = node.getParent()\n // A link is inline, so a link's alignment is its paragraph's.\n const block = $isLinkNode(parent) ? $findMatchingParent(node, (one) => $isElementNode(one) && !one.isInline()) : undefined\n setCurrent($isElementNode(block) ? block.getFormatType() : $isElementNode(node) ? node.getFormatType() : (parent?.getFormatType() ?? ''))\n })\n const outdent = t('admin.richText.outdent', 'Outdent') ?? 'Outdent'\n const indent = t('admin.richText.indent', 'Indent') ?? 'Indent'\n return (\n <>\n <ButtonGroup>\n {alignments.map(({ format, Icon, label }) => {\n const name = label(t) ?? format\n return (\n <Toggle key={format} variant=\"outline\" size=\"sm\" className=\"max-sm:h-11 max-sm:min-w-11\" title={name} aria-label={name} pressed={current === format} onPressedChange={() => (setCurrent(format), editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, format))}>\n <Icon className=\"size-4\" />\n </Toggle>\n )\n })}\n </ButtonGroup>\n <ButtonGroup>\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={outdent} aria-label={outdent} onClick={() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)}>\n <IndentDecreaseIcon className=\"size-4 rtl:-scale-x-100\" />\n </Button>\n <Button type=\"button\" variant=\"outline\" size=\"icon-sm\" className=\"max-sm:size-11\" title={indent} aria-label={indent} onClick={() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)}>\n <IndentIncreaseIcon className=\"size-4 rtl:-scale-x-100\" />\n </Button>\n </ButtonGroup>\n </>\n )\n}\n\n/** The address a new link starts with: the link editor opens on it so it is typed over, and no save may hold it. */\nconst newLink = 'https://'\n\n/** Unwraps every link still holding the placeholder (except the selected one when `keepSelected`) back into its words, so a saved value holds none. */\nconst dropPlaceholders = (editor: LexicalEditor, keepSelected: boolean) =>\n editor.update(() => {\n const selection = $getSelection()\n const kept = keepSelected && $isRangeSelection(selection) ? $findMatchingParent(getSelectedNode(selection), $isLinkNode) : null\n for (const { node } of $dfs()) {\n if (!$isLinkNode(node) || node.getURL() !== newLink || node.is(kept)) continue\n for (const child of node.getChildren()) node.insertBefore(child)\n node.remove()\n }\n })\n\nconst holdsPlaceholder = (state: EditorState) => state.read(() => $dfs().some(({ node }) => $isLinkNode(node) && node.getURL() === newLink))\n\n/** Whether the focus is in the link editor's own box, where a placeholder is being typed over. */\nconst typingLink = () => document.activeElement?.classList.contains('link-input') === true\n\nconst VERTICAL_GAP = 10\nconst HORIZONTAL_OFFSET = 5\n\nfunction setFloatingElemPositionForLinkEditor(targetRect: DOMRect | null, floatingElem: HTMLElement, anchorElem: HTMLElement): void {\n const scrollerElem = anchorElem.parentElement\n if (targetRect === null || !scrollerElem) {\n floatingElem.style.opacity = '0'\n floatingElem.style.transform = 'translate(-10000px, -10000px)'\n return\n }\n const floatingElemRect = floatingElem.getBoundingClientRect()\n const anchorElementRect = anchorElem.getBoundingClientRect()\n const editorScrollerRect = scrollerElem.getBoundingClientRect()\n let top = targetRect.top - VERTICAL_GAP\n let left = targetRect.left - HORIZONTAL_OFFSET\n if (top < editorScrollerRect.top) top += floatingElemRect.height + targetRect.height + VERTICAL_GAP * 2\n if (left + floatingElemRect.width > editorScrollerRect.right) left = editorScrollerRect.right - floatingElemRect.width - HORIZONTAL_OFFSET\n // A right-to-left page puts the bubble's start on the right, which the\n // physical offset above would push off the edge.\n if (left < editorScrollerRect.left) left = editorScrollerRect.left\n top -= anchorElementRect.top\n left -= anchorElementRect.left\n floatingElem.style.opacity = '1'\n floatingElem.style.transform = `translate(${left}px, ${top}px)`\n}\n\nfunction FloatingLinkEditor({ editor, isLink, setIsLink, anchorElem, isLinkEditMode, setIsLinkEditMode, t }: {\n editor: LexicalEditor\n isLink: boolean\n setIsLink: (isLink: boolean) => void\n anchorElem: HTMLElement\n isLinkEditMode: boolean\n setIsLinkEditMode: (editing: boolean) => void\n t: Translate\n}): JSX.Element {\n const editorRef = useRef<HTMLDivElement | null>(null)\n const inputRef = useRef<HTMLInputElement>(null)\n const [linkUrl, setLinkUrl] = useState('')\n const [editedLinkUrl, setEditedLinkUrl] = useState(newLink)\n // False until the address is edited, so the placeholder it opens on is not flagged invalid.\n const [edited, setEdited] = useState(false)\n const [lastSelection, setLastSelection] = useState<BaseSelection | null>(null)\n\n const $updateLinkEditor = useCallback(() => {\n const selection = $getSelection()\n if ($isRangeSelection(selection)) {\n const node = getSelectedNode(selection)\n const linkParent = $findMatchingParent(node, $isLinkNode)\n if (linkParent) setLinkUrl(linkParent.getURL())\n else if ($isLinkNode(node)) setLinkUrl(node.getURL())\n else setLinkUrl('')\n if (isLinkEditMode) setEditedLinkUrl(linkUrl)\n }\n const editorElem = editorRef.current\n const nativeSelection = window.getSelection()\n const activeElement = document.activeElement\n if (editorElem === null) return\n const rootElement = editor.getRootElement()\n if (selection !== null && nativeSelection !== null && rootElement !== null && rootElement.contains(nativeSelection.anchorNode) && editor.isEditable()) {\n const domRect: DOMRect | undefined = nativeSelection.focusNode?.parentElement?.getBoundingClientRect()\n if (domRect) {\n domRect.y += 40\n setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem)\n }\n setLastSelection(selection)\n } else if (!activeElement || !activeElement.classList.contains('link-input')) {\n if (rootElement !== null) setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem)\n setLastSelection(null)\n setIsLinkEditMode(false)\n setLinkUrl('')\n }\n return true\n }, [anchorElem, editor, setIsLinkEditMode, isLinkEditMode, linkUrl])\n\n useEffect(() => {\n const scrollerElem = anchorElem.parentElement\n const update = () => editor.getEditorState().read(() => void $updateLinkEditor())\n window.addEventListener('resize', update)\n scrollerElem?.addEventListener('scroll', update)\n return () => {\n window.removeEventListener('resize', update)\n scrollerElem?.removeEventListener('scroll', update)\n }\n }, [anchorElem.parentElement, editor, $updateLinkEditor])\n\n useEffect(\n () =>\n mergeRegister(\n editor.registerUpdateListener(({ editorState }) => editorState.read(() => void $updateLinkEditor())),\n editor.registerCommand(SELECTION_CHANGE_COMMAND, () => ($updateLinkEditor(), true), COMMAND_PRIORITY_LOW),\n editor.registerCommand(KEY_ESCAPE_COMMAND, () => (isLink ? (setIsLink(false), true) : false), COMMAND_PRIORITY_HIGH),\n ),\n [editor, $updateLinkEditor, setIsLink, isLink],\n )\n\n useEffect(() => {\n editor.getEditorState().read(() => void $updateLinkEditor())\n }, [editor, $updateLinkEditor])\n\n useEffect(() => {\n if (isLinkEditMode && inputRef.current) {\n inputRef.current.focus()\n setIsLink(true)\n }\n }, [isLinkEditMode, isLink])\n\n useEffect(() => setEdited(false), [isLinkEditMode])\n // Every address written passes `safeUrl`, the check the server makes of a write.\n const allowed = safeUrl(editedLinkUrl) && editedLinkUrl.trim() !== newLink\n const handleLinkSubmission = () => {\n if (lastSelection === null || !allowed) return\n if (linkUrl !== '') editor.dispatchCommand(TOGGLE_LINK_COMMAND, editedLinkUrl.trim())\n setEditedLinkUrl(newLink)\n setIsLinkEditMode(false)\n }\n const cancel = () => {\n if (linkUrl === newLink) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)\n setIsLinkEditMode(false)\n setIsLink(false)\n }\n const address = t('admin.richText.linkAddress', 'Link address') ?? 'Link address'\n const cancelling = t('admin.richText.cancelLink', 'Cancel') ?? 'Cancel'\n const saving = t('admin.richText.saveLink', 'Save link') ?? 'Save link'\n const editing = t('admin.richText.editLink', 'Edit link') ?? 'Edit link'\n const removing = t('admin.richText.removeLink', 'Remove link') ?? 'Remove link'\n return (\n <div ref={editorRef} data-link-editor className=\"absolute top-0 left-0 z-20 w-full max-w-sm rounded-md opacity-0 shadow-md\">\n {!isLink ? null : isLinkEditMode ? (\n <div className=\"bg-popover flex items-center gap-2 rounded-md border p-1 ps-2\">\n <Input\n ref={inputRef}\n dir=\"ltr\"\n type=\"url\"\n aria-label={address}\n aria-invalid={edited && !allowed ? true : undefined}\n value={editedLinkUrl}\n onChange={(event) => (setEdited(true), setEditedLinkUrl(event.target.value))}\n onBlur={() => setTimeout(() => {\n if (editor.getRootElement()?.contains(document.activeElement) || editorRef.current?.contains(document.activeElement)) return\n dropPlaceholders(editor, false)\n setIsLinkEditMode(false)\n })}\n onKeyDown={(event) => {\n if (event.key === 'Enter') {\n event.preventDefault()\n handleLinkSubmission()\n } else if (event.key === 'Escape') {\n event.preventDefault()\n cancel()\n }\n }}\n className=\"link-input flex-grow\"\n />\n <Button type=\"button\" size=\"icon\" variant=\"ghost\" title={cancelling} aria-label={cancelling} onClick={cancel} className=\"shrink-0\">\n <XIcon className=\"size-4\" />\n </Button>\n <Button type=\"button\" size=\"icon\" disabled={!allowed} title={saving} aria-label={saving} onClick={handleLinkSubmission} className=\"shrink-0\">\n <CheckIcon className=\"size-4\" />\n </Button>\n </div>\n ) : (\n <div className=\"bg-popover flex items-center justify-between gap-2 rounded-md border p-1 ps-2\">\n <a href={safeUrl(linkUrl) ? linkUrl : undefined} target=\"_blank\" rel=\"noopener noreferrer\" dir=\"ltr\" className=\"overflow-hidden text-sm text-ellipsis whitespace-nowrap\">\n {linkUrl}\n </a>\n <div className=\"flex\">\n <Button type=\"button\" size=\"icon\" variant=\"ghost\" title={editing} aria-label={editing} onClick={() => (setEditedLinkUrl(linkUrl), setIsLinkEditMode(true))}>\n <PencilIcon className=\"size-4\" />\n </Button>\n <Button type=\"button\" size=\"icon\" variant=\"destructive\" title={removing} aria-label={removing} onClick={() => editor.dispatchCommand(TOGGLE_LINK_COMMAND, null)}>\n <TrashIcon className=\"size-4\" />\n </Button>\n </div>\n </div>\n )}\n </div>\n )\n}\n\n/** Draws the link editor over the anchor while the selection sits in one link. */\nfunction FloatingLinkEditorPlugin({ anchorElem, isLinkEditMode, setIsLinkEditMode, t }: {\n anchorElem: HTMLDivElement | null\n isLinkEditMode: boolean\n setIsLinkEditMode: (editing: boolean) => void\n t: Translate\n}): JSX.Element | null {\n const [editor] = useLexicalComposerContext()\n const [activeEditor, setActiveEditor] = useState(editor)\n const [isLink, setIsLink] = useState(false)\n useEffect(() => {\n const $updateToolbar = () => {\n const selection = $getSelection()\n if ($isRangeSelection(selection)) {\n const focusLinkNode = $findMatchingParent(getSelectedNode(selection), $isLinkNode)\n if (!focusLinkNode) {\n setIsLink(false)\n return\n }\n const badNode = selection.getNodes().filter((node) => !$isLineBreakNode(node)).find((node) => {\n const linkNode = $findMatchingParent(node, $isLinkNode)\n return !focusLinkNode.is(linkNode) || (linkNode && !linkNode.is(focusLinkNode))\n })\n setIsLink(!badNode)\n } else if ($isNodeSelection(selection)) {\n const node = selection.getNodes()[0]\n setIsLink(!!node && ($isLinkNode(node.getParent()) || $isLinkNode(node)))\n }\n }\n // A placeholder is dropped once the link editor is no longer open on it, so a click elsewhere or a save never stores one.\n const stale = (editorState: EditorState) =>\n editorState.read(() => {\n const selection = $getSelection()\n const inside = $isRangeSelection(selection) ? $findMatchingParent(getSelectedNode(selection), $isLinkNode) : null\n return $dfs().some(({ node }) => $isLinkNode(node) && node.getURL() === newLink && !node.is(inside))\n })\n return mergeRegister(\n editor.registerUpdateListener(({ editorState }) => {\n editorState.read(() => $updateToolbar())\n if (stale(editorState)) dropPlaceholders(editor, true)\n }),\n editor.registerCommand(SELECTION_CHANGE_COMMAND, (_payload, newEditor) => ($updateToolbar(), setActiveEditor(newEditor), false), COMMAND_PRIORITY_CRITICAL),\n editor.registerCommand(BLUR_COMMAND, () => (setTimeout(() => typingLink() || dropPlaceholders(editor, false)), false), COMMAND_PRIORITY_LOW),\n )\n }, [editor])\n if (!anchorElem) return null\n return createPortal(\n <FloatingLinkEditor editor={activeEditor} isLink={isLink} anchorElem={anchorElem} setIsLink={setIsLink} isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} t={t} />,\n anchorElem,\n )\n}\n\nconst headingPick = (tag: HeadingTagType, Icon: RichTextPick['Icon'], level: string): RichTextPick => ({\n key: tag, label: { en: `Heading ${level}`, ar: `عنوان ${level}` }, Icon, keywords: ['heading', 'header', tag], block: true, onSelect: ({ editor }) => toHeading(editor, tag),\n})\nexport const defaultPicks: readonly RichTextPick[] = [\n { key: 'paragraph', label: { en: 'Paragraph', ar: 'فقرة' }, Icon: TextIcon, keywords: ['normal', 'paragraph', 'p', 'text'], block: true, onSelect: ({ editor }) => toParagraph(editor) },\n headingPick('h1', Heading1Icon, '1'),\n headingPick('h2', Heading2Icon, '2'),\n headingPick('h3', Heading3Icon, '3'),\n { key: 'number', label: { en: 'Numbered list', ar: 'قائمة مرقمة' }, Icon: ListOrderedIcon, keywords: ['numbered list', 'ordered list', 'ol'], block: true, onSelect: listOf(INSERT_ORDERED_LIST_COMMAND) },\n { key: 'bullet', label: { en: 'Bulleted list', ar: 'قائمة نقطية' }, Icon: ListIcon, keywords: ['bulleted list', 'unordered list', 'ul'], block: true, onSelect: listOf(INSERT_UNORDERED_LIST_COMMAND) },\n { key: 'check', label: { en: 'Check list', ar: 'قائمة مهام' }, Icon: ListTodoIcon, keywords: ['check list', 'todo list'], block: true, onSelect: listOf(INSERT_CHECK_LIST_COMMAND) },\n {\n key: 'quote',\n label: { en: 'Quote', ar: 'اقتباس' },\n Icon: QuoteIcon,\n keywords: ['block quote'],\n block: true,\n onSelect: ({ editor, isActive }) => (isActive ? undefined : editor.update(() => $setBlocksType($getSelection(), () => $createQuoteNode()))),\n },\n { key: 'horizontalRule', label: { en: 'Divider', ar: 'خط فاصل' }, Icon: MinusIcon, keywords: ['horizontal rule', 'divider', 'hr'], onSelect: ({ editor }) => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined) },\n]\n\n/** The React subclasses of nodes that draw React, each of the same type as the DOM-free class in the node list and extending it. */\nexport const defaultViews: readonly Klass<LexicalNode>[] = [HorizontalRuleView]\n\n/** A node list as the browser draws it: each class swapped for its React subclass where one extends it. */\nexport const viewsOf = (nodes: readonly Klass<LexicalNode>[], views: readonly Klass<LexicalNode>[] = defaultViews) =>\n nodes.map((node) => views.find((view) => view.prototype instanceof node) ?? node)\n\n/**\n * The nodes and picks every editor and viewer under it draws with, provided once by a screen from\n * the project's own list so the editor, viewer and server read the same nodes. The registry's own where none is provided.\n */\nexport const RichTextContext = createContext<{\n nodes: readonly Klass<LexicalNode>[]\n picks: readonly RichTextPick[]\n /** What a project's own node mounts inside every editor beside its nodes, handed the field's control props (for example the upload node's dialog). */\n Plugin?: ComponentType<ControlProps>\n}>({ nodes: viewsOf(defaultNodes), picks: defaultPicks })\n\nclass PickerOption extends MenuOption {\n item: RichTextPick\n title: string\n constructor(item: RichTextPick, title: string) {\n super(item.key)\n this.item = item\n this.title = title\n }\n}\n\nfunction PickerMenu({ options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }: {\n options: PickerOption[]\n selectedIndex: number | null\n selectOptionAndCleanUp: (option: PickerOption) => void\n setHighlightedIndex: (index: number) => void\n}) {\n const itemRefs = useRef<(HTMLButtonElement | null)[]>([])\n useEffect(() => {\n if (selectedIndex !== null) itemRefs.current[selectedIndex]?.scrollIntoView({ block: 'nearest', behavior: 'auto' })\n }, [selectedIndex])\n return (\n <div className=\"border-border bg-popover text-popover-foreground h-min min-w-48 overflow-hidden rounded-lg border p-1 shadow-xl\" role=\"listbox\">\n {options.map((option, index) => {\n const { Icon } = option.item\n return (\n <button\n key={option.key}\n ref={(element) => {\n itemRefs.current[index] = element\n }}\n type=\"button\"\n role=\"option\"\n aria-selected={selectedIndex === index}\n className={cn('flex w-full items-center gap-2 rounded-md px-3 py-2 text-start text-sm outline-none', selectedIndex === index ? 'bg-accent' : 'hover:bg-muted')}\n onClick={() => selectOptionAndCleanUp(option)}\n onMouseDown={(event) => event.preventDefault()}\n onMouseEnter={() => setHighlightedIndex(index)}\n >\n <span className=\"text-muted-foreground flex size-5 items-center justify-center\"><Icon className=\"size-4\" /></span>\n <span className=\"flex-1\">{option.title}</span>\n </button>\n )\n })}\n </div>\n )\n}\n\nfunction SlashMenu({ locale, picks }: { locale: string; picks: readonly RichTextPick[] }): JSX.Element {\n const [editor] = useLexicalComposerContext()\n const [queryString, setQueryString] = useState<string | null>(null)\n const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', { minLength: 0 })\n const options = useMemo(() => {\n const all = picks.map((item) => new PickerOption(item, pickLabel(item, locale)))\n if (!queryString) return all\n // The typed word is compared as text, never as a pattern: a lone bracket would throw in a RegExp.\n const word = queryString.toLowerCase()\n return all.filter((option) => option.title.toLowerCase().includes(word) || (option.item.keywords ?? []).some((keyword) => keyword.includes(word)))\n }, [queryString, locale, picks])\n const onSelectOption = useCallback(\n (selected: PickerOption, nodeToRemove: TextNode | null, closeMenu: () => void) => {\n editor.update(() => {\n nodeToRemove?.remove()\n selected.item.onSelect({ editor, isActive: false })\n closeMenu()\n })\n },\n [editor],\n )\n return (\n <LexicalTypeaheadMenuPlugin<PickerOption>\n onQueryChange={setQueryString}\n onSelectOption={onSelectOption}\n triggerFn={checkForTriggerMatch}\n options={options}\n menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) => {\n if (!anchorElementRef.current || !options.length) return null\n const anchorRect = anchorElementRef.current.getBoundingClientRect()\n return createPortal(\n <div className=\"fixed z-[1000]\" style={{ left: anchorRect.left, top: anchorRect.bottom + 4 }}>\n <PickerMenu options={options} selectedIndex={selectedIndex} selectOptionAndCleanUp={selectOptionAndCleanUp} setHighlightedIndex={setHighlightedIndex} />\n </div>,\n document.body,\n )\n }}\n />\n )\n}\n\n/**\n * Clears the direction of the root and every block, so Lexical writes `dir=\"auto\"` per block and\n * each follows its first strong letter. Written as a history merge, so it is no change the form would save.\n */\nfunction DirectionPlugin() {\n const [editor] = useLexicalComposerContext()\n useEffect(() => {\n editor.update(() => {\n const root = $getRoot()\n root.setDirection(null)\n for (const block of root.getChildren()) if ($isElementNode(block) && block.getDirection() !== null) block.setDirection(null)\n }, { tag: HISTORY_MERGE_TAG })\n }, [editor])\n return null\n}\n\nfunction ToolbarRow({ t, locale, picks, setLinkEditMode, direction }: { t: Translate; locale: string; picks: readonly RichTextPick[]; setLinkEditMode: (editing: boolean) => void; direction: RichTextDirection }) {\n const [editor] = useLexicalComposerContext()\n const bar = useRef<HTMLDivElement>(null)\n const [blockType, setBlockType] = useState('paragraph')\n const [activeEditor, setActiveEditor] = useState(editor)\n useEffect(\n () => activeEditor.registerCommand(SELECTION_CHANGE_COMMAND, (_payload, next) => (setActiveEditor(next), false), COMMAND_PRIORITY_CRITICAL),\n [activeEditor],\n )\n // One tab stop for the whole toolbar: the button that last held focus, or the first enabled one, is tabbable. A button that changes state hands the stop on, so it never lands on a disabled one.\n useEffect(() => {\n const row = bar.current!\n let last: Element | null = null\n const enabled = () => [...row.querySelectorAll('button:not(:disabled)')] as HTMLElement[]\n const sync = () => {\n const all = [...row.querySelectorAll('button')] as HTMLElement[]\n // A button disabled under focus (Undo, once nothing is left to undo) drops focus to the page: hand it to the nearest button that still takes it.\n if (last instanceof HTMLButtonElement && last.disabled && (document.activeElement === last || document.activeElement === document.body)) {\n const at = all.indexOf(last)\n all.slice(at + 1).concat(all.slice(0, at).reverse()).find((one) => !(one as HTMLButtonElement).disabled)?.focus()\n }\n const keep = enabled().find((one) => one === last) ?? enabled()[0]\n for (const one of all) one.tabIndex = one === keep ? 0 : -1\n }\n const focused = (event: FocusEvent) => ((last = event.target as Element), sync())\n // Focus leaving the row forgets the button, so a later change cannot pull focus back in; a button disabled under focus is left to the hand-off above.\n const left = (event: FocusEvent) => {\n if (!row.contains(event.relatedTarget as Node | null) && !(event.target as HTMLButtonElement).disabled) last = null\n }\n sync()\n const watch = new MutationObserver(sync)\n watch.observe(row, { subtree: true, childList: true, attributeFilter: ['disabled'] })\n row.addEventListener('focusin', focused)\n row.addEventListener('focusout', left)\n return () => (watch.disconnect(), row.removeEventListener('focusin', focused), row.removeEventListener('focusout', left))\n }, [])\n const move = (event: ReactKeyboardEvent<HTMLDivElement>) => {\n // Alt+Left, Cmd+Arrow and Ctrl+Home are the browser's own, not the toolbar's.\n if (event.altKey || event.ctrlKey || event.metaKey) return\n const items = [...event.currentTarget.querySelectorAll('button:not(:disabled)')] as HTMLElement[]\n const at = items.indexOf(document.activeElement as HTMLElement)\n // A menu's own items sit in a portal, outside this row, so only a button of the row moves focus.\n if (at < 0) return\n const step = { ArrowRight: direction === 'rtl' ? -1 : 1, ArrowLeft: direction === 'rtl' ? 1 : -1 }[event.key]\n const to = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : step === undefined ? undefined : (at + step + items.length) % items.length\n if (to === undefined) return\n event.preventDefault()\n items[to]!.focus()\n }\n return (\n // Sticky under the page's header, so no ancestor may scroll or clip (the editor box uses overflow-clip). The tint is opaque so scrolling text never shows through.\n // On a phone it is one sideways-scrolling line, with contained width so it does not widen the page.\n <div ref={bar} role=\"toolbar\" aria-label={t('admin.richText.toolbar', 'Formatting')} aria-orientation=\"horizontal\" onKeyDown={move} className=\"border-border bg-[color-mix(in_oklab,var(--muted)_20%,var(--background))] sticky top-[var(--header-bar-height,var(--header-height,0px))] z-9 flex flex-wrap items-center gap-1.5 border-b px-2 py-2 max-sm:flex-nowrap max-sm:overflow-x-auto max-sm:contain-inline-size max-sm:[&>*]:shrink-0\">\n <HistoryButtons activeEditor={activeEditor} t={t} />\n <BlockDropdown toolbar={{ activeEditor, blockType, setBlockType }} t={t} locale={locale} picks={picks} />\n <FormatButtons t={t} />\n <LinkButton t={t} setLinkEditMode={setLinkEditMode} />\n <ClearFormatting t={t} />\n <AlignButtons t={t} />\n </div>\n )\n}\n\nconst configOf = (namespace: string, nodes: readonly Klass<LexicalNode>[], value: RichTextValue, editable: boolean) => ({\n namespace,\n editable,\n editorState: JSON.stringify(value),\n nodes: [...nodes],\n onError(error: Error) {\n throw error\n },\n theme,\n})\n\n/**\n * What stops these nodes drawing a stored value, or nothing: the first node type the value holds\n * that no class of the list registers, or `''` where it nests past the server's bound or Lexical's own parse refuses it.\n */\nexport const unreadable = (value: RichTextValue, nodes: readonly Klass<LexicalNode>[]): string | undefined => {\n const type = unknownType({ node: value.root, types: typesOf(nodes) })\n if (type !== undefined) return type\n try {\n createEditor({ nodes: [...nodes], onError: (error) => { throw error } }).parseEditorState(JSON.stringify(value))\n return undefined\n } catch {\n return ''\n }\n}\n\n/**\n * One node of a value no editor here can mount, drawn as its words; a node no class registers is\n * marked by its type. Stops at the server's depth bound with an ellipsis rather than overflowing the stack.\n */\nfunction Walked({ node, types, inline, depth = richTextBounds.depth }: { node: RichTextNode; types: ReadonlySet<string>; inline: boolean; depth?: number }): ReactNode {\n if (depth <= 0) return '…'\n if (!node || typeof node !== 'object') return null\n if (node.type === 'linebreak') return <br />\n if (typeof node.text === 'string' && types.has(node.type)) return node.text\n const children = Array.isArray(node.children) ? node.children : []\n const runs = children.some((child) => typeof child?.text === 'string')\n const drawn = children.map((child, at) => <Walked key={at} node={child} types={types} inline={runs} depth={depth - 1} />)\n if (!types.has(node.type)) {\n return (\n <span data-unknown-node={node.type} className=\"border-destructive text-destructive rounded border border-dashed px-1\">\n {node.type}{typeof node.text === 'string' ? `: ${node.text}` : null}{drawn.length ? <> {drawn}</> : null}\n </span>\n )\n }\n return inline ? <span>{drawn}</span> : <div className=\"mb-2\">{drawn}</div>\n}\n\n/** A stored value this editor cannot mount, drawn read-only by `Walked`. It reports no change, so the field stays out of every save and nothing it holds is lost. */\nfunction Unreadable({ value, nodes, problem, direction, t, id, describedBy }: {\n value: RichTextValue\n nodes: readonly Klass<LexicalNode>[]\n problem: string\n direction: RichTextDirection\n t: Translate\n id?: string\n describedBy?: string\n}): ReactElement {\n const said = problem\n ? (t('admin.richText.unknownNode', 'This text holds a \"{type}\" node this editor cannot draw, so it is shown as its words and cannot be changed here.') ?? '').replace('{type}', () => problem)\n : (t('admin.richText.unreadable', 'This editor cannot read this text, so it is shown as its words and cannot be changed here.') ?? '')\n return (\n <div dir={direction} data-rich-text-view={id ?? ''} data-rich-text-unreadable={problem} className=\"grid gap-2\">\n <p role=\"alert\" className=\"text-destructive text-sm\">{said}</p>\n <div id={id} aria-describedby={describedBy} className=\"text-sm leading-5\">\n {(Array.isArray(value.root.children) ? value.root.children : []).map((node, at) => <Walked key={at} node={node} types={typesOf(nodes)} inline={false} />)}\n </div>\n </div>\n )\n}\n\n/**\n * The editor, over the screen's nodes and picks (`RichTextContext`) unless handed its own.\n * Uncontrolled past its first value, as Lexical is. A stored value it cannot mount is drawn by `Unreadable` and reports nothing.\n */\nexport function RichTextEditor({ value, onChange, direction, t, locale, id, placeholder, describedBy, invalid, required, nodes, picks, control }: {\n value: unknown\n onChange: (value: RichTextValue) => void\n direction: RichTextDirection\n t: Translate\n locale: string\n id?: string\n placeholder?: string\n describedBy?: string\n invalid?: boolean\n required?: boolean\n nodes?: readonly Klass<LexicalNode>[]\n picks?: readonly RichTextPick[]\n /** The field's control props, which the screen's `Plugin` is handed. */\n control?: ControlProps\n}): ReactElement {\n const provided = use(RichTextContext)\n const { Plugin } = provided\n const drawn = nodes ?? provided.nodes\n const [held] = useState(() => valueOf(value))\n const [problem] = useState(() => unreadable(held, drawn))\n // Built only over a value these nodes read, so a value nested past the bound never reaches `JSON.stringify` or Lexical.\n const [initial] = useState(() => (problem === undefined ? configOf('MithaqRichText', drawn, held, true) : undefined))\n const [isLinkEditMode, setIsLinkEditMode] = useState(false)\n const [anchor, setAnchor] = useState<HTMLDivElement | null>(null)\n if (problem !== undefined || !initial) return <Unreadable value={held} nodes={drawn} problem={problem ?? ''} direction={direction} t={t} id={id} describedBy={describedBy} />\n return (\n <div className=\"border-input bg-background overflow-clip rounded-lg border shadow-xs\" dir={direction} data-rich-text={id}>\n <LexicalComposer initialConfig={initial}>\n <ToolbarRow t={t} locale={locale} picks={picks ?? provided.picks} setLinkEditMode={setIsLinkEditMode} direction={direction} />\n <div className=\"relative\" ref={setAnchor}>\n <RichTextPlugin\n contentEditable={\n <ContentEditable\n id={id}\n // Named by the field's label, since a `for` cannot reach a div.\n aria-labelledby={id ? `${id}-label` : undefined}\n aria-describedby={describedBy}\n aria-invalid={invalid ? true : undefined}\n aria-required={required ? true : undefined}\n aria-placeholder={placeholder ?? ''}\n className=\"min-h-40 px-4 py-3 text-sm leading-6 outline-none\"\n placeholder={<div className=\"text-muted-foreground pointer-events-none absolute start-0 top-0 overflow-hidden px-4 py-3 text-sm text-ellipsis select-none\">{placeholder ?? ''}</div>}\n />\n }\n ErrorBoundary={LexicalErrorBoundary}\n />\n </div>\n {/* A value holding an unconfirmed link is never handed on, so an autosave while the link editor is open sends no placeholder the server refuses. */}\n <OnChangePlugin ignoreSelectionChange onChange={(state) => holdsPlaceholder(state) || onChange(state.toJSON() as unknown as RichTextValue)} />\n <HistoryPlugin />\n <ListPlugin />\n <CheckListPlugin />\n <LinkPlugin validateUrl={safeUrl} />\n <FloatingLinkEditorPlugin anchorElem={anchor} isLinkEditMode={isLinkEditMode} setIsLinkEditMode={setIsLinkEditMode} t={t} />\n <HorizontalRulePlugin />\n <SlashMenu locale={locale} picks={picks ?? provided.picks} />\n <DirectionPlugin />\n {Plugin && control ? <Plugin {...control} /> : null}\n </LexicalComposer>\n </div>\n )\n}\n\n/**\n * The read view: the same nodes, never editable, remounted when the value changes. A link is drawn\n * through Lexical's `sanitizeUrl`, which answers `about:blank` for unsupported schemes, so a\n * `javascript:` or `data:` address that reached a column past the server's check is never followed.\n */\nexport function RichTextViewer({ value, direction, t, className, id, describedBy, nodes }: {\n value: unknown\n direction: RichTextDirection\n t: Translate\n className?: string\n id?: string\n describedBy?: string\n nodes?: readonly Klass<LexicalNode>[]\n}): ReactElement {\n const drawn = nodes ?? use(RichTextContext).nodes\n const held = useMemo(() => valueOf(value), [value])\n // Asked before serializing, so a value nested past the bound never reaches `JSON.stringify` or Lexical.\n const problem = useMemo(() => unreadable(held, drawn), [held, drawn])\n if (problem !== undefined) return <Unreadable value={held} nodes={drawn} problem={problem} direction={direction} t={t} id={id} describedBy={describedBy} />\n const serialized = JSON.stringify(held)\n return (\n <div dir={direction} data-rich-text-view={id ?? ''}>\n <LexicalComposer key={serialized} initialConfig={configOf('MithaqRichTextViewer', drawn, held, false)}>\n <RichTextPlugin\n contentEditable={<ContentEditable id={id} aria-describedby={describedBy} className={cn('text-sm leading-5 outline-none [&_h1]:text-xl [&_h2]:text-lg [&_h3]:text-base [&_p:last-child]:mb-0', className)} />}\n ErrorBoundary={LexicalErrorBoundary}\n />\n <ListPlugin />\n <CheckListPlugin />\n <LinkPlugin validateUrl={safeUrl} />\n <DirectionPlugin />\n </LexicalComposer>\n </div>\n )\n}\n\n/** The `richText` kind's control: the editor, or the viewer where the field is read only. An editor holding nothing but empty paragraphs reports `null`. */\nexport function RichTextControl(props: ControlProps): ReactElement {\n const { describedBy, error, id, locale, placeholder, readOnly, t, value } = props\n const direction = useDirection() === 'rtl' ? 'rtl' : 'ltr'\n const change = changeOf(props)\n if (readOnly) return <RichTextViewer value={value} direction={direction} t={t} id={id} describedBy={describedBy} />\n return (\n <RichTextEditor\n value={value}\n onChange={(next) => change(blank(next) ? null : next)}\n direction={direction}\n t={t}\n locale={locale ?? 'en'}\n id={id}\n placeholder={placeholder}\n describedBy={describedBy}\n invalid={!!error}\n required={ariaRequired(props.field) === true}\n control={props}\n />\n )\n}\n\n/** A rich text value as a record page reads it: the viewer, over the page's direction, and nothing where it holds none. */\nexport function RichTextValueView({ value, t }: { value: unknown; t: Translate }): ReactNode {\n const direction = useDirection() === 'rtl' ? 'rtl' : 'ltr'\n return value ? <RichTextViewer value={value} direction={direction} t={t} /> : null\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 0709e5fd4e8b0eaeda128c63d9bb3d4bd885ef9f053b7374a40c0223e7aee874\n",
29
- "type": "registry:component",
30
- "target": "@components/mithaq/rich-text.tsx"
31
- },
32
- {
33
- "path": "registry/rich-text-nodes.ts",
34
- "content": "export { defaultNodes } from 'mithaq/rich-text'\n\n// A project's own list spreads this, in a file both the editor and the server import:\n//\n// export const richTextNodes = [...defaultNodes, CalloutNode]\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 12555780fdbc7bc6b283e1c56c498b12265f7c417359a0d4eff71d8207bdb3cd\n",
35
- "type": "registry:lib",
36
- "target": "@components/mithaq/rich-text-nodes.ts"
37
- }
38
- ],
39
- "type": "registry:component"
40
- }
@@ -1,96 +0,0 @@
1
- {
2
- "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
- "name": "mithaq-screens-host",
4
- "title": "Mithaq screens host",
5
- "description": "What a TanStack Start app draws every screen with: the server functions that resolve the actor, the access report and the screen, the frame and the page each generated route file hands its screen's glob to, the list read through TanStack Query off its qs URL, the form's landing after a save and its guard on leaving with a change, the fetch plug's client, the /unauthorized route, the public landing page at /, the language switch its header and the app's auth pages draw, and the frame, boxes and password toggle those auth pages share. Nothing in it names a screen; `mithaq generate screens` writes each screen's glob and route files beside it. What it imports from the app, which no item writes: `localeOf`, `locales`, `refusalKey` and the type `Locale` from `src/copy.ts`; `wanted` from `src/redirect.ts`; `useCopy` from `src/routes/__root.tsx`, whose route context names the app's own copy key as `app`; the project as the package `asas`; `@/components/mithaq/form`, which the form item writes. The app's `createRouter` spreads `flatSearch` from `src/screen-list.tsx`, so the router reads and writes the list's URL pairs as strings.",
6
- "dependencies": [
7
- "@tanstack/react-query",
8
- "@tanstack/react-router",
9
- "@tanstack/react-start"
10
- ],
11
- "registryDependencies": [
12
- "button",
13
- "card",
14
- "input",
15
- "input-group",
16
- "@mithaq/mithaq-field-shell",
17
- "@mithaq/mithaq-nodes",
18
- "@mithaq/mithaq-list",
19
- "@mithaq/mithaq-nav"
20
- ],
21
- "files": [
22
- {
23
- "path": "registry/host/screens.tsx",
24
- "content": "import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query'\nimport { notFound, redirect, useMatches, useRouter } from '@tanstack/react-router'\nimport { createIsomorphicFn, createServerFn, createServerOnlyFn } from '@tanstack/react-start'\nimport { Suspense, lazy, useEffect, useState, type ComponentType, type ReactElement, type ReactNode } from 'react'\nimport { holds, navOf, referencesIn, type AccessReport, type ClientKayan, type LoaderTools, type Row, type ScreenClient } from 'mithaq/admin'\nimport { ScreenContext, useScreen, type ScreenState } from 'mithaq/admin/react'\nimport { actingAs, arm, authArm, caused, clientOf, readsOf, takesKey } from './api.ts'\nimport { clampedHref, routedHref } from './links.ts'\nimport { mergedHeaders } from './request-headers.ts'\nimport { documentHead, localeOf, locales, translator, type Locale } from './copy.ts'\nimport { FieldValue, type FormClient, type FormProps } from './components/mithaq/form.tsx'\nimport type { Page } from './components/mithaq/list.tsx'\nimport { QueryRefused, findOf, listRaw, prefixOf, readQuery, rulesOf, scopedTo, withList, type RawQuery } from './components/mithaq/list-query.ts'\nimport { createsIn, newTitleOf, titleOf } from './components/mithaq/controls.tsx'\nimport { NavLinks, NodeButton, OwnButton, RecordFrame, formIn, holdFocus, frameOf, listIn, nodesOf, pageButtonOf, pageOf, readsAsValues, refOf, relatedIn, rowTitle, scopeOf, type Draw, type Refs, type TreePage } from './components/mithaq/nodes.tsx'\nimport { idsOf } from './components/mithaq/relationship-control.tsx'\nimport { ScreenForm } from './screen-form.tsx'\nimport { ScreenList, askedFor, readsDrafts, rowReadsDrafts, rowsKey } from './screen-list.tsx'\nimport { screenNamed } from './screen-names.ts'\nimport { slide } from './slide.ts'\nimport { useCopy } from './routes/__root.tsx'\n\n/**\n * The built asas, on the server alone. The module cache serves one build for\n * every call, since `asas` stays out of the server bundle; a changed `asas` takes a restart.\n */\nexport const getAsas = createServerOnlyFn(async () => (await import('asas')).asas)\n\ntype InProcess = Record<string, { operations: Record<string, (input: object, options: object) => Promise<unknown>> }>\n\n/**\n * Each kayan's operations in process. In the platform app a read of a kayan asks `tenant: 'all'`,\n * which the runner grants platform staff there alone, so staff read every organization's rows; a\n * global is one organization's row and asks for none, and a write never asks.\n */\nconst inProcess = async (asas: Awaited<ReturnType<typeof getAsas>>): Promise<InProcess> => {\n const kayan = asas.kayan as unknown as InProcess\n if (!(await import('asas')).onPlatform()) return kayan\n const reads = new Set(asas.operations.filter((one) => one.effect === 'read' && !one.single).map((one) => `${one.kayan}/${one.name}`))\n return Object.fromEntries(Object.entries(kayan).map(([name, one]) => [name, {\n operations: Object.fromEntries(Object.entries(one.operations).map(([operation, run]) =>\n [operation, reads.has(`${name}/${operation}`) ? (input: object, options: object) => run(input, { ...options, tenant: 'all' }) : run])),\n }]))\n}\n\n/** One screen's generated glob: each file its references name, keyed by the path a reference prints, loaded only when called. */\nexport type Files = Readonly<Record<string, () => Promise<unknown>>>\n\ntype Drawn = ComponentType<Record<string, unknown>>\nconst loaded = new Map<string, Drawn>()\nconst drawn = new Map<string, Drawn>()\n\nconst load = async (files: Files, reference: string) => {\n const at = reference.indexOf('#')\n const exported = ((await files[reference.slice(0, at)]?.()) as Record<string, Drawn> | undefined)?.[reference.slice(at + 1)]\n if (exported) loaded.set(reference, exported)\n return exported\n}\n\n// One `lazy` per reference. A loaded file is handed over as a thenable that\n// resolves at once, which React reads without suspending; an unloaded one\n// (hydration before any loader ran) suspends.\nconst drawnOf = (files: Files, reference: string) => {\n let found = drawn.get(reference)\n if (!found) {\n found = lazy(() => {\n const ready = loaded.get(reference)\n return ready\n ? ({ then: (resolve: (module: { default: Drawn }) => void) => resolve({ default: ready }) } as unknown as Promise<{ default: Drawn }>)\n : load(files, reference).then((exported) => ({ default: exported ?? (() => null) }))\n })\n drawn.set(reference, found)\n }\n return found\n}\n\nconst refsOf = (files: Files, screen: ScreenClient): Refs =>\n Object.fromEntries(\n referencesIn(screen)\n .filter((reference) => Object.hasOwn(files, reference.slice(0, reference.indexOf('#'))))\n .map((reference) => [reference, drawnOf(files, reference)]),\n ) as Refs\n\nconst preload = (files: Files, ...parts: unknown[]) => Promise.all(referencesIn(parts).map((reference) => loaded.get(reference) ?? load(files, reference)))\n\n/** This build's screens by name: what a request's screen name is checked against before anything is read by it. */\nconst screensOf = (targets: unknown) =>\n Object.fromEntries(Object.entries(targets as Record<string, { capability?: string } | undefined>).filter(([, target]) => target?.capability === 'screen'))\n\nconst segments = (path: string) => path.split('/').filter(Boolean)\n\n/**\n * The page a path asks for, a kayan's or the screen's own, longest first and a\n * static segment before a parameter, so `/courses/new` is the create page, not\n * an edit of a row called `new`.\n */\nexport const matchPage = (screen: ScreenClient, path: string) => {\n const asked = segments(path)\n const pages = [\n ...Object.entries(screen.pages).map(([name, page]) => ({ kayan: undefined, name, page, parts: segments(page.path) })),\n ...Object.values(screen.kayan).flatMap((kayan) => Object.entries(kayan.pages).map(([name, page]) => ({ kayan: kayan.name as string | undefined, name, page, parts: segments(page.path) }))),\n ]\n .sort((a, b) => b.parts.length - a.parts.length || a.parts.filter((one) => one.startsWith(':')).length - b.parts.filter((one) => one.startsWith(':')).length)\n for (const one of pages) {\n if (one.parts.length !== asked.length) continue\n const params: Record<string, string> = {}\n if (one.parts.every((part, at) => (part.startsWith(':') ? ((params[part.slice(1)] = asked[at]!), true) : part === asked[at]))) {\n return { kayan: one.kayan, page: one.name, params }\n }\n }\n return undefined\n}\n\ntype Actor = {\n readonly id?: string\n readonly name?: string | null\n readonly email?: string | null\n readonly impersonatedBy?: unknown\n readonly activeOrganizationId?: string | null\n readonly organizations?: readonly { readonly id: string; readonly name: string; readonly slug?: string }[]\n}\n\n// `'unauthorized'` is answered before the projection is read, so a refused screen sends no kayan, field, row or page;\n// what is returned is serialized into the document, so projection and report are cut to what the actor may read.\nconst resolveScreen = async (asked: unknown) => {\n const [{ getRequest, setResponseHeader }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])\n const name = screenNamed(screensOf(asas.targets), asked)\n const request = getRequest()\n const ask = (path: string) => asas.handler(new Request(new URL(path, request.url), { headers: request.headers }))\n await slide({ strategies: asas.auth.strategies, headers: request.headers, setResponseHeader })\n // Off the strategy loop, not the provider's session route: in process, so\n // no request counts against its rate limit.\n const actor = (await asas.auth.actor(request.headers)) as Actor | null\n if (!actor?.email) return undefined\n const report = (await (await ask('/api/access')).json()) as AccessReport\n if (!report.screens?.includes(name)) return 'unauthorized' as const\n const full = (asas.targets as unknown as Record<string, { client?: ScreenClient } | undefined>)[name]!.client!\n const kayan = Object.fromEntries(\n Object.entries(full.kayan)\n .filter(([one]) => report.collections?.[one]?.read === true)\n .map(([one, value]) => [one, { ...value, fields: value.fields.filter((field) => report.collections?.[one]?.fields?.[field.name]?.read === true) }]),\n )\n const { onPlatform, platformEntry, PLATFORM_SCREEN } = await import('asas')\n const platform = platformEntry(actor)\n // The other screens of this app the actor may enter. The platform's own name\n // is left out of a tenant app's list, so the one link to it is `platformEntry`'s alone.\n const served = screensOf(asas.targets) as Record<string, { client: ScreenClient }>\n const siblings = (report.screens ?? [])\n .filter((one) => one !== name && Object.hasOwn(served, one) && (one === PLATFORM_SCREEN) === onPlatform())\n .map((one) => ({ name: one, path: served[one]!.client.path }))\n const others = [...siblings, ...(platform ? [{ name: PLATFORM_SCREEN, path: platform }] : [])]\n return {\n asas,\n request,\n name,\n report,\n // Whole, so a page of a kayan the cut leaves out is told from a path naming no page.\n full,\n user: {\n ...(actor.id ? { id: actor.id } : {}),\n name: actor.name ?? actor.email,\n email: actor.email,\n // Only the fact; the id of the person behind the session stays on the server.\n ...(actor.impersonatedBy ? { impersonating: true } : {}),\n ...(others.length ? { screens: others } : {}),\n ...(actor.organizations && {\n organizations: actor.organizations.map(({ id, name, slug }) => ({ id, name, slug })),\n activeOrganizationId: actor.activeOrganizationId ?? null,\n }),\n },\n access: {\n // So `holds` answers a `screen.<name>` key in the browser as on the server.\n ...(report.screens === undefined ? {} : { screens: report.screens }),\n collections: Object.fromEntries(Object.keys(kayan).map((one) => [one, report.collections![one]])),\n // For a key neither a screen's nor an operation's, such as `user.ban`.\n ...(report.permissions === undefined ? {} : { permissions: report.permissions }),\n } satisfies AccessReport,\n // The nav is cut from the whole report, before the report is cut to the screen's kayan.\n screen: { ...full, kayan, nav: navOf(full, report) } as ScreenClient,\n }\n}\n\n/** The layout route's answer: who, what they may do, and the screen. */\nexport const loadScreen = createServerFn({ method: 'GET', strict: { output: false } })\n // Checked against this build's screens in `resolveScreen`.\n .validator((screen: unknown) => screen)\n .handler(async ({ data }) => {\n const found = await resolveScreen(data)\n return found === 'unauthorized' ? found : found ? { name: found.name, user: found.user, access: found.access, screen: found.screen } : null\n })\n\n/**\n * What `/unauthorized` offers a signed-in actor: every screen the report lists for it, `null` without a session.\n * `from` is only matched against this build's screens and pages, so nothing a caller writes is said back.\n */\nexport const loadEnterable = createServerFn({ method: 'GET' })\n .validator((asked?: { from?: unknown; locale?: unknown }) => ({\n from: typeof asked?.from === 'string' ? asked.from : undefined,\n locale: localeOf(typeof asked?.locale === 'string' ? asked.locale : undefined),\n }))\n .handler(async ({ data }): Promise<{\n screens: { name: string; path: string }[]\n refused?: { page: string; screen: { name: string; path: string } }\n email?: string\n organizations?: { id: string; name: string; slug?: string }[]\n others?: { id: string; name: string; slug?: string }[]\n impersonating?: string\n } | null> => {\n const [{ getRequest }, asas, { onPlatform, PLATFORM_SCREEN }] = await Promise.all([import('@tanstack/react-start/server'), getAsas(), import('asas')])\n const request = getRequest()\n const actor = (await asas.auth.actor(request.headers)) as Actor | null\n if (!actor?.email) return null\n const report = (await (await asas.handler(new Request(new URL('/api/access', request.url), { headers: request.headers }))).json()) as AccessReport\n const screens = screensOf(asas.targets) as Record<string, { client: ScreenClient }>\n const entered = (report.screens ?? [])\n .filter((name) => Object.hasOwn(screens, name) && (name === PLATFORM_SCREEN) === onPlatform())\n .map((name) => ({ name, path: screens[name]!.client.path }))\n const impersonating = actor.impersonatedBy ? { impersonating: actor.name ?? actor.email } : {}\n if (entered.length) {\n const { from, locale } = data\n const home = from === undefined ? undefined : entered.find((one) => from === one.path || from.startsWith(`${one.path}/`))\n const match = home && matchPage(screens[home.name]!.client, from!.slice(home.path.length))\n if (!home || !match) return { screens: entered, ...impersonating }\n const page = nameOfPage({ screen: screens[home.name]!.client, name: home.name, match, copy: { t: translator(locale), locale } })\n return { screens: entered, refused: { page, screen: home }, ...impersonating }\n }\n const memberships = actor.organizations ?? []\n const active = memberships.find((one) => one.id === actor.activeOrganizationId)\n return {\n screens: entered,\n email: actor.email,\n organizations: (active ? [active] : memberships).map(({ id, name, slug }) => ({ id, name, slug })),\n others: onPlatform() ? [] : memberships.filter((one) => one !== active).map(({ id, name, slug }) => ({ id, name, slug })),\n ...impersonating,\n }\n })\n\nexport { askedFor }\n\nconst holdsForm = (page: TreePage) => formIn(nodesOf(page)) !== undefined\n\nconst relatedOf = (screen: ScreenClient, kayan: ClientKayan) => Object.values(screen.kayan).filter((one) => kayan.fields.some((field) => field.relationTo === one.name))\n\nconst scopeTo = (node: { where?: string }, id: unknown) => ({ [node.where!]: { equals: id } })\n\n/** One page's answer: which page the path asked for, the row a form draws, the rows a list draws, and what this actor may write. */\nexport const loadPage = createServerFn({ method: 'GET', strict: { output: false } })\n .validator((asked: { screen: unknown; path: string; query: Record<string, unknown> }) => ({\n screen: asked.screen,\n path: String(asked.path),\n query: askedFor(asked.query),\n }))\n .handler(async ({ data }) => {\n const found = await resolveScreen(data.screen)\n if (!found) return 'signed out' as const\n if (found === 'unauthorized') return found\n const { asas, screen } = found\n const match = matchPage(screen, data.path)\n // A page of a kayan the actor may not read is refused like a page whose key\n // it does not hold, so one role meets one denial page.\n if (!match) return matchPage(found.full, data.path) ? ('unauthorized' as const) : null\n // A page of the screen's own reads nothing, but one naming a key is served\n // only to an actor the report says holds it; its nav entry is only chrome.\n if (match.kayan === undefined) {\n const permission = screen.pages[match.page]?.permission\n if (permission !== undefined && !holds(found.report, permission)) return 'unauthorized' as const\n return { name: found.name, user: found.user, access: found.access, screen, kayan: undefined, page: match.page, value: null, first: undefined, related: {}, refused: [], creates: false, operations: {}, writable: {}, drafts: undefined }\n }\n const kayan = screen.kayan[match.kayan]\n if (!kayan) return null\n const page = kayan.pages[match.page]!\n const tree = 'page' in page ? page : undefined\n const actor = (await asas.auth.actor(found.request.headers)) ?? undefined\n const operations = (await inProcess(asas)) as unknown as Record<string, { operations: Record<string, (input: object, options: { actor?: unknown; draft?: boolean }) => Promise<unknown>> } | undefined>\n const globals = asas.global as unknown as typeof operations\n const readRow = async (id: string, draft: boolean) =>\n (await operations[kayan.name]!.operations.findByID!({ id }, { actor, ...(draft && { draft }) }).catch((thrown: unknown) => {\n if (!caused(thrown)) throw thrown\n return null\n })) as Row | null\n const report = found.access.collections?.[kayan.name]\n // A create page with no field to write is refused like a page whose key is\n // not held, not drawn as an empty form.\n if (match.page === 'create' && !createsIn({ kayan, report })) return 'unauthorized' as const\n // A kayan that keeps drafts, for an actor holding `findVersions` (the key a\n // draft read and a draft save ask): the actor edits the latest draft.\n // Without it, or where it leaves this row out, the actor reads the row and saves there.\n const drafting = await rowReadsDrafts({\n kayan,\n operations: report?.operations,\n id: kayan.single ? undefined : match.params.id,\n findVersions: (input) => operations[kayan.name]!.operations.findVersions!(input, { actor }).catch((thrown: unknown) => {\n if (!caused(thrown)) throw thrown\n return null\n }) as Promise<{ docs: readonly unknown[] } | null>,\n })\n let value: Row | null = null\n if (kayan.single ? !tree || holdsForm(tree) : match.params.id !== undefined) {\n if (kayan.single) value = (await globals[kayan.name]!.operations.findOne!({}, { actor })) as Row | null\n else if (match.params.id) {\n value = await readRow(match.params.id, drafting)\n if (!value) return null\n }\n }\n // A create page reads Draft from the start, since no published copy exists.\n // A plain read finds only a published row, which tells draft from changed.\n let drafts: FormProps['drafts']\n if (drafting && tree && holdsForm(tree)) {\n const published = value ? await readRow(String(value.id), false) : null\n drafts = {\n status: !published ? 'draft' : value!._status === 'draft' ? 'changed' : 'published',\n ...(kayan.drafts!.autosave && { autosave: kayan.drafts!.autosave.interval }),\n }\n }\n // The list this page draws, or the one it opens over. A URL the list does not\n // offer is refused before `find` is asked anything, and the page draws the refusal.\n const list = kayan.pages.list && 'page' in kayan.pages.list ? kayan.pages.list : undefined\n const listPage = tree && listIn(nodesOf(tree)) ? tree : tree?.mode ? list : undefined\n let first: { input: Record<string, unknown>; rows: Page } | undefined\n const node = listPage && listIn(nodesOf(listPage))\n if (node) {\n try {\n const input = findOf({\n kayan,\n query: readQuery(data.query, rulesOf({ kayan, node, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, kayan), locales } })),\n node,\n locales,\n client: relatedOf(screen, kayan),\n })\n first = { input, rows: (await operations[kayan.name]!.operations.find!(input, { actor, ...(drafting && { draft: true }) })) as Page }\n } catch (thrown) {\n if (!(thrown instanceof QueryRefused)) throw thrown\n }\n }\n // Each list of another kayan the page draws over its row. A kayan the screen\n // does not cross (one the actor may not read) is read nothing, and so is a URL the list does not offer.\n const related: Record<string, { input: Record<string, unknown>; rows: Page }> = {}\n // Lists whose read the server refused, which the page says it cannot show rather than drawing nothing.\n const refused: string[] = []\n for (const one of tree && value ? relatedIn(nodesOf(tree), scopeOf({ values: {}, record: value, access: found.access, kayan })) : []) {\n const target = screen.kayan[one.of!]\n if (!target) continue\n try {\n const input = scopedTo(findOf({\n kayan: target,\n query: readQuery(listRaw(data.query, prefixOf(one)), rulesOf({ kayan: target, node: one, maxLimit: screen.maxLimit, context: { client: relatedOf(screen, target), locales } })),\n node: one,\n locales,\n client: relatedOf(screen, target),\n }), scopeTo(one, value!.id))\n const draft = readsDrafts(target, found.access.collections?.[target.name]?.operations)\n related[prefixOf(one)] = { input, rows: (await operations[target.name]!.operations.find!(input, { actor, ...(draft && { draft }) })) as Page }\n } catch (thrown) {\n if (!(thrown instanceof QueryRefused) && !caused(thrown)) throw thrown\n if ((thrown as { status?: unknown }).status === 403) refused.push(prefixOf(one))\n }\n }\n const own = (name: string) => (asas.operations as readonly { kayan: string; name: string; input: Parameters<typeof takesKey>[0] }[]).find((one) => one.kayan === kayan.name && one.name === name)\n const update = own('update')\n // The row a relationship title points at, read as this actor; one the actor\n // may not read names nothing. Only its id and title field go into the page's\n // data, which is serialized into the document.\n const titleField = kayan.fields.find((one) => one.name === kayan.useAsTitle && one.kind === 'relationship')\n const titledKayan = titleField ? screen.kayan[titleField.relationTo ?? ''] : undefined\n const titledId = value && titledKayan ? idsOf(value[titleField!.name])[0] : undefined\n const titledRow = titledId === undefined ? undefined : ((await operations[titledKayan!.name]?.operations.findByID?.({ id: titledId }, { actor }).catch((thrown: unknown) => {\n if (!caused(thrown)) throw thrown\n return null\n })) as Row | null | undefined)\n const titledField = titledKayan?.useAsTitle\n const titledBy = titledRow && titledField ? ({ id: titledRow.id, [titledField]: titledRow[titledField] } as Row) : undefined\n return {\n name: found.name,\n user: found.user,\n access: found.access,\n screen,\n kayan: kayan.name,\n page: match.page,\n value,\n first,\n related,\n refused,\n creates: createsIn({ kayan, report }),\n operations: report?.operations ?? {},\n writable: report?.fields ?? {},\n drafts,\n ...(titledBy && { titledBy }),\n // Whether the kayan's update takes the `expectedUpdatedAt` step's key, which the form then sends.\n ...(update && takesKey(update.input, 'expectedUpdatedAt') && { expectedUpdatedAt: true }),\n // And which of delete and restore do, for the Discards and the Versions tab's restore.\n expectedUpdatedAtOn: ['delete', 'restoreVersion'].filter((name) => { const one = own(name); return !!one && takesKey(one.input, 'expectedUpdatedAt') }),\n }\n })\n\ntype ScreenData = Exclude<Awaited<ReturnType<typeof loadScreen>>, 'unauthorized' | null>\ntype PageData = Exclude<Awaited<ReturnType<typeof loadPage>>, 'signed out' | 'unauthorized' | null>\n\nconst pathOf = (href: string) => href.split(/[?#]/)[0]!\n\n/**\n * A screen's frame route's loader: resolves the session, access report and screen, and preloads the providers' files.\n * It is not `beforeLoad` because route context must be serializable and the projection's `rules` are `unknown`.\n */\nexport const frameLoader = async ({ screen, files, href }: { screen: string; files: Files; href: string }) => {\n const found = await loadScreen({ data: screen })\n if (!found) throw redirect({ to: '/auth/sign-in', search: { redirect: href } })\n if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })\n await preload(files, found.screen.providers)\n return found\n}\n\n// Server: in process as this request's actor, so the first HTML has what a loader reads. Browser: the page's `fetch`.\n// Only the browser's client is held to the served read's cap; in process a `limit` of 0 is allowed.\nconst toolsOf = createIsomorphicFn()\n .server(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => {\n const [{ getRequest }, asas] = await Promise.all([import('@tanstack/react-start/server'), getAsas()])\n const request = getRequest()\n const actor = (await asas.auth.actor(request.headers)) ?? undefined\n const kayan = (await inProcess(asas)) as unknown as Parameters<typeof actingAs>[0]\n return {\n client: actingAs(kayan, actor),\n request: (path, init) => asas.handler(new Request(new URL(path, request.url), { ...init, headers: mergedHeaders(request.headers, init?.headers) })),\n access,\n maxLimit,\n }\n })\n .client(async ({ access, maxLimit }: Pick<LoaderTools, 'access' | 'maxLimit'>): Promise<LoaderTools> => ({ client: clientOf(arm), request: fetch, access, maxLimit }))\n\n/**\n * A screen page route's loader: the page `path` names and only the files it draws. A page naming a `permission`\n * is served only to an actor holding it; a page whose file exports a `loader` gets its return as `loaderData`.\n */\nexport const pageLoader = async ({\n screen,\n files,\n path,\n query,\n href,\n}: {\n screen: string\n files: Files\n path: string\n query: Record<string, unknown>\n href: string\n}) => {\n const found = await loadPage({ data: { screen, path, query } })\n if (found === 'signed out') throw redirect({ to: '/auth/sign-in', search: { redirect: href } })\n if (found === 'unauthorized') throw redirect({ to: '/unauthorized', search: { from: pathOf(href) } })\n if (!found) throw notFound()\n // A list asked for a page past its last is sent to its last, replacing the\n // entry the URL made, so back does not land on it again.\n const clamped = found.first && clampedHref({ href, page: found.first.rows.page, totalPages: found.first.rows.totalPages })\n if (clamped) throw redirect({ href: clamped, replace: true })\n await preload(files, ...partsOf(found))\n if (found.kayan === undefined) {\n const component = found.screen.pages[found.page]?.component\n const module = component ? ((await files[component.slice(0, component.indexOf('#'))]?.()) as { loader?: unknown } | undefined) : undefined\n if (typeof module?.loader === 'function') {\n return { ...found, loaderData: (await (module.loader as (tools: LoaderTools) => Promise<unknown>)(await toolsOf({ access: found.access, maxLimit: found.screen.maxLimit }))) as unknown }\n }\n }\n return found\n}\n\nconst nameOfPage = ({ screen, name, match, row, titledBy, copy }: {\n screen: ScreenClient\n name: string\n match: { kayan?: string; page: string }\n row?: Row | null\n titledBy?: Row | null\n copy: { t: (key: string, fallback?: string) => string | undefined; locale: Locale }\n}): string => {\n if (match.kayan === undefined) return screen.nav.find((entry) => !entry.kayan && entry.page === match.page)?.label?.[copy.locale] ?? copy.t(`screens.${name}`, name) ?? name\n const kayan = screen.kayan[match.kayan]!\n const page = kayan.pages[match.page]\n const many = !!page && 'page' in page && listIn(nodesOf(page)) !== undefined\n if (match.page === 'create') return newTitleOf({ kayan, copy })\n const field = kayan.fields.find((one) => one.name === kayan.useAsTitle)\n const target = field?.kind === 'relationship' ? screen.kayan[field.relationTo ?? ''] : undefined\n const titled = many ? undefined : target ? titledBy && rowTitle({ kayan: target, row: titledBy, locale: copy.locale }) : row && rowTitle({ kayan, row, locale: copy.locale })\n return titled || titleOf({ kayan, copy, many })\n}\n\n/** A page's route `head`: the tab names the page, then the app. A route that drew its not-found has no data and says the page was not found. */\nexport const pageHead = (data: PageData | undefined, context: { locale: Locale; app: string }) =>\n documentHead(context, (t) =>\n data ? nameOfPage({ screen: data.screen, name: data.name, match: data, row: data.value, titledBy: 'titledBy' in data ? data.titledBy : undefined, copy: { t, locale: context.locale } }) : t('admin.notFoundTitle', 'Page not found'))\n\nconst partsOf = (found: PageData): unknown[] => {\n if (found.kayan === undefined) return [found.screen.pages[found.page]]\n const kayan = found.screen.kayan[found.kayan]!\n const page = kayan.pages[found.page]!\n return [page, 'page' in page && page.mode ? kayan.pages.list : undefined, Object.values(found.screen.kayan).map((one) => one.fields)]\n}\n\nconst useScreenState = (data: ScreenData): ScreenState => {\n const { dir, locale, t } = useCopy()\n return { t, locale, dir, screen: data.screen, access: data.access, user: data.user, client: clientOf(arm) }\n}\n\n/**\n * The bar that says the session is an impersonation, with the way out. Stopping\n * is a sign-out: the staff member's own session lives in the platform app under\n * that app's cookie, and the sign-out answers where they go next.\n */\nexport function ImpersonationBar({ name }: { name: string }): ReactElement {\n const { t } = useCopy()\n return (\n <div role=\"alert\" data-impersonating=\"\" className=\"flex flex-wrap items-center justify-between gap-2 bg-amber-500 px-4 py-2 text-sm font-medium text-black\">\n <span>\n {t('admin.impersonating', 'You are impersonating')} <strong>{name}</strong>.\n </span>\n <button\n type=\"button\"\n className=\"rounded-md border border-black/40 px-3 py-1 font-semibold\"\n // `replace`, so Back does not return to the page signed out of.\n onClick={() => void authArm('sign-out').then((answered) => window.location.replace(String(answered.body?.url ?? '/auth/sign-in')))}\n >\n {t('admin.stopImpersonating', 'Stop impersonating')}\n </button>\n </div>\n )\n}\n\nfunction ScreenBar({ user }: { user: ScreenData['user'] }): ReactElement | null {\n const { t } = useCopy()\n const [failed, setFailed] = useState(false)\n useEffect(() => {\n const show = (event: Event) => setFailed((event as CustomEvent<boolean>).detail)\n window.addEventListener('mithaq:after-commit', show)\n return () => window.removeEventListener('mithaq:after-commit', show)\n }, [])\n return (\n <>\n {'impersonating' in user && user.impersonating ? <ImpersonationBar name={user.name} /> : null}\n {failed ? (\n <p role=\"status\" className=\"rounded-md border border-amber-500/50 px-3 py-2 text-sm\">\n {t('admin.afterCommitFailed', 'Saved, but a follow-up action failed, so something this save should have caused, such as an email or a notification, may be missing. Please contact an administrator.')}\n </p>\n ) : null}\n </>\n )\n}\n\n/** The screen's own frame: its providers, outermost first, around the frame's bar and the page. */\nexport function ScreenFrame({ files, data, children }: { files: Files; data: ScreenData; children: ReactNode }): ReactElement {\n const refs = refsOf(files, data.screen)\n // One per frame: made per request on the server, so no row one actor read\n // reaches another's render; held for the frame's life in the browser.\n const [queries] = useState(() => new QueryClient())\n // A hydrating page has had no loader run in the browser, so every file the\n // frame and page draw is asked for at once, off the answer the page's loader\n // sent, not one round trip per file as React reaches it.\n const page = useMatches({ select: (matches) => matches.at(-1)?.loaderData as PageData | undefined })\n useState(() => typeof window !== 'undefined' && void preload(files, data.screen.providers, ...(page?.screen ? partsOf(page) : [])))\n // A link inside the screen is followed by the router, so a tap reads the\n // next page's data instead of loading a whole document.\n const router = useRouter()\n const base = data.screen.path\n useEffect(() => {\n const follow = (event: MouseEvent) => {\n const href = routedHref({ event, origin: window.location.origin, base })\n if (href === undefined) return\n event.preventDefault()\n void router.navigate({ href })\n }\n document.addEventListener('click', follow)\n return () => document.removeEventListener('click', follow)\n }, [router, base])\n // Tab stays inside an open dialog, alert dialog or sheet, as in a native modal.\n useEffect(() => holdFocus(document.body), [])\n // A component that changed what the page reads, with no kayan operation to\n // say so, asks for a re-read of the lists and the page's loader without a\n // reload, so the scroll stays.\n useEffect(() => {\n const again = () => void Promise.all([queries.invalidateQueries(), router.invalidate()])\n window.addEventListener('mithaq:refresh', again)\n return () => window.removeEventListener('mithaq:refresh', again)\n }, [queries, router])\n // Suspends only while a hydrating page's files load.\n return (\n <QueryClientProvider client={queries}>\n <ScreenContext value={useScreenState(data)}>\n <Suspense fallback={null}>\n {data.screen.providers.reduceRight<ReactElement>(\n (inner, path) => {\n const Provider = refOf({ screen: { refs }, place: 'provider', path })!\n return <Provider>{inner}</Provider>\n },\n <>\n <ScreenBar user={data.user} />\n {children}\n </>,\n )}\n </Suspense>\n </ScreenContext>\n </QueryClientProvider>\n )\n}\n\ntype PageViewProps = {\n files: Files\n data: PageData & { loaderData?: unknown }\n query: RawQuery\n onQuery: (search: Record<string, string>) => void\n}\n\n/** One page, inside what the host provides for it: the screen, and on a kayan's page that kayan and the row, which `useKayan()` and `useRecord()` read. */\nexport function ScreenPageView(props: PageViewProps): ReactElement {\n const { data } = props\n const state = useScreenState(data)\n // So a shell marks its home link on the home page alone.\n const own = data.screen.pages[data.page]?.path ?? '/'\n return (\n <ScreenContext value={data.kayan === undefined ? { ...state, path: own === '/' ? data.screen.path : `${data.screen.path}${own}` } : { ...state, kayan: data.screen.kayan[data.kayan], record: data.value }}>\n <PageBody {...props} />\n </ScreenContext>\n )\n}\n\n/**\n * A screen page route's not-found, drawn under the screen's frame in the shell\n * its pages use, so the nav stays and the way back is the screen's home. The\n * shell is the first page tree's, which is every page's in this template.\n */\nexport function ScreenNotFound({ files, screen: name }: { files: Files; screen: string }): ReactElement {\n const { t } = useCopy()\n const screen = useScreen()\n const refs = refsOf(files, screen)\n // A page that opens over its list is drawn inside the list's shell, so it names none to draw here.\n const framed = Object.values(screen.kayan).flatMap((kayan) => Object.values(kayan.pages).flatMap((page) => ('page' in page && !page.mode ? [{ kayan, page }] : [])))[0]\n const back = (t('auth.backTo', 'Back to {screen}') ?? 'Back to {screen}').replace('{screen}', () => t(`screens.${name}`, name) ?? name)\n const regions = {\n header: <h1 className=\"text-2xl font-bold tracking-tight\">{t('admin.notFoundTitle', 'Page not found')}</h1>,\n nav: <nav aria-label={t('admin.navigation', 'Navigation')}><NavLinks /></nav>,\n main: (\n <div className=\"flex flex-col items-start gap-4\">\n <p className=\"text-muted-foreground text-sm\">{t('admin.notFound', 'That page does not exist.')}</p>\n <a className=\"text-foreground text-sm underline underline-offset-4\" href={screen.path}>{back}</a>\n </div>\n ),\n }\n if (!framed) return <main className=\"p-6\">{regions.header}{regions.main}</main>\n return <>{frameOf({ screen: { refs, ...framed }, t, path: framed.page.page.shell, regions })}</>\n}\n\nfunction PageBody({ files, data, query, onQuery }: PageViewProps): ReactElement | null {\n const refs = refsOf(files, data.screen)\n const router = useRouter()\n const queries = useQueryClient()\n const { locale, t } = useCopy()\n if (data.kayan === undefined) {\n const Page = refOf({ screen: { refs }, place: 'page', path: data.screen.pages[data.page]?.component })\n return Page ? <Page loaderData={data.loaderData} /> : null\n }\n const kayan = data.screen.kayan[data.kayan]!\n const declared = kayan.pages[data.page]!\n if ('component' in declared) {\n const Whole = refOf({ screen: { refs }, place: 'component', path: declared.component })\n return Whole ? <Whole page={declared} /> : null\n }\n const page = declared\n const base = data.screen.path\n const hrefOf = (target: ClientKayan, pageName: string | undefined, id?: string) => {\n const path = pageName === undefined ? undefined : target.pages[pageName]?.path\n return path ? `${base}${id ? path.replace(':id', encodeURIComponent(id)) : path}` : undefined\n }\n // A row opens its kayan's edit page, else the one page whose path draws a\n // row (how the platform opens a record it only reads).\n const rowPage = (target: ClientKayan) => (target.pages.edit ? 'edit' : Object.keys(target.pages).find((one) => target.pages[one]!.path.includes(':id')))\n const related = relatedOf(data.screen, kayan)\n const go = (href: string | undefined) => void (href ? router.navigate({ href }) : router.invalidate())\n const written = <Written,>(write: Promise<Written>, name = kayan.name) =>\n write.then((out) => {\n void queries.invalidateQueries({ queryKey: rowsKey(name) })\n return out\n })\n\n // A list of another kayan on this page: the rows whose `where` holds the row\n // the page draws. The screen crosses only the kayan the actor may read, so one\n // it may not is refused here, as is one whose read the server refused.\n const relatedList = (target: TreePage, node: Parameters<NonNullable<Draw['list']>>[0]['node'], index: number, own?: ReactElement) => {\n const listed = data.screen.kayan[node.of!]\n // Both look the same to the viewer: rows it cannot see.\n if (!listed || data.refused.includes(prefixOf(node))) {\n return (\n <p key={index} role=\"alert\" data-mithaq-refused-list={node.of} className=\"rounded-md border border-destructive/50 px-3 py-2 text-sm text-destructive\">\n {t('admin.cannotSee', 'You can\\'t see this')}\n </p>\n )\n }\n const id = data.value?.id\n if (id === undefined) return null\n const prefix = prefixOf(node)\n const report = data.access.collections?.[listed.name]\n const targets = relatedOf(data.screen, listed)\n return (\n <ScreenList\n key={index}\n props={{\n kayan: listed,\n node,\n client: targets,\n api: readsOf([listed, ...targets]),\n creates: createsIn({ kayan: listed, report }),\n operations: report?.operations ?? {},\n writes: (field) => report?.fields?.[field]?.update === true,\n access: data.access,\n t,\n locale,\n locales,\n href: (row) => hrefOf(listed, rowPage(listed), row) ?? '#',\n newHref: '#',\n screen: { refs, kayan: listed, page: target },\n edit: {\n client: {\n create: (input) => written(arm[listed.name]!.create!(input), listed.name),\n update: (input) => written(arm[listed.name]!.update!(input), listed.name),\n delete: (input) => written(arm[listed.name]!.delete!(input), listed.name),\n },\n writes: (field, on) => report?.fields?.[field]?.[on] === true,\n prefill: { [node.where!]: id },\n },\n }}\n raw={listRaw(query, prefix)}\n onQuery={(search) => onQuery(withList({ raw: query, prefix, written: search }))}\n first={data.related[prefix]}\n scope={scopeTo(node, id)}\n find={(input, options) => arm[listed.name]!.find!(input, options) as Promise<Page>}\n run={(operation, input) => arm[listed.name]![operation]!(input)}\n own={own}\n />\n )\n }\n\n const draw = (target: TreePage): Draw => {\n return {\n kayan,\n copy: { t, locale },\n issues: {},\n // A page drawn here holds no form, so a `when` reads the row and the report alone.\n scope: scopeOf({ values: {}, record: data.value, access: data.access, kayan }),\n // A leaf is the row's value drawn by its cell, read and never edited.\n control: (leaf) => {\n const field = kayan.fields.find((one) => one.name === (typeof leaf === 'string' ? leaf : leaf.field))\n return field ? (\n <FieldValue\n key={field.name}\n kayan={kayan}\n field={field}\n leaf={typeof leaf === 'string' ? undefined : leaf}\n row={data.value}\n copy={{ t, locale }}\n api={readsOf(related)}\n kayans={related}\n screen={{ refs, kayan, page: target }}\n />\n ) : null\n },\n component: (path, index) => {\n const Component = refOf({ screen: { refs }, place: 'component', path })\n return Component ? <Component key={index} page={target} /> : null\n },\n layout: ({ path, regions, index }) => frameOf({ screen: { refs, kayan, page: target }, t, path, regions, key: index }),\n // A list node naming a component is drawn by it, which reads through\n // `useList()` what the registry's list is handed.\n list: ({ node, index, component }) => {\n const Own = component ? refOf({ screen: { refs }, place: 'component', path: component }) : undefined\n if (node.of) return relatedList(target, node, index, Own ? <Own page={target} /> : undefined)\n return (\n <ScreenList\n key={index}\n props={{\n kayan,\n client: related,\n api: readsOf([kayan, ...related]),\n creates: data.creates && !!kayan.pages.create,\n operations: data.operations,\n writes: (field) => data.writable[field]?.update === true,\n access: data.access,\n t,\n locale,\n locales,\n href: (id) => hrefOf(kayan, rowPage(kayan), id) ?? '#',\n newHref: hrefOf(kayan, 'create') ?? '#',\n screen: { refs, kayan, page: target },\n }}\n raw={query}\n onQuery={onQuery}\n first={data.first}\n find={(input, options) => arm[kayan.name]!.find!(input, options) as Promise<Page>}\n run={(operation, input) => arm[kayan.name]![operation]!(input)}\n own={Own ? <Own page={target} /> : undefined}\n />\n )\n },\n nav: () => <NavLinks />,\n button: (node, index) => {\n const button = pageButtonOf({ kayan, node, allowed: data.operations })\n if (!button) return null\n const call = async (input: Row) => {\n await written(arm[kayan.name]![button.name]!({ ...input, ...(data.value?.id !== undefined && { id: data.value.id }) }))\n await router.invalidate()\n }\n // A project's button component is handed a run that asks the confirm\n // first; the registry's own button asks it before calling.\n const Own = node.component ? refOf({ screen: { refs }, place: 'button', path: node.component }) : undefined\n return Own ? (\n <OwnButton key={index} Own={Own} operation={button} busy={false} copy={{ t, locale }} call={call} />\n ) : (\n <NodeButton key={index} node={node} copy={{ t, locale }} busy={false} run={() => void call({})} />\n )\n },\n }\n }\n const drawn = (target: TreePage) => pageOf({ refs, kayan, page: target }, draw(target))\n if (readsAsValues({ page, value: data.value, operations: data.operations })) return <>{drawn(page)}</>\n\n const listHref = hrefOf(kayan, 'list')\n const client: FormClient = kayan.single\n ? {\n create: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),\n update: ({ id: _id, ...input }) => arm[kayan.name]!.update!(input),\n delete: () => Promise.reject(new Error(`\"${kayan.name}\" is one row and declares no delete.`)),\n }\n : {\n create: (input, options) => written(arm[kayan.name]!.create!(input, options)),\n update: (input, options) => written(arm[kayan.name]!.update!(input, options)),\n delete: (input) => written(arm[kayan.name]!.delete!(input)),\n run: (operation, input) => written(arm[kayan.name]![operation]!(input)),\n findByID: (input) => arm[kayan.name]!.findByID!(input),\n }\n const form = (\n <ScreenForm\n kayan={kayan}\n screen={{ refs, kayan, page }}\n client={client}\n value={data.value}\n t={t}\n locale={locale}\n locales={locales}\n writes={(field, on) => data.writable[field]?.[on] === true}\n kayans={related}\n api={readsOf(related)}\n operations={data.operations}\n related={draw(page).list}\n edit={(id) => hrefOf(kayan, 'edit', id)}\n list={listHref}\n drafts={data.drafts}\n expectedUpdatedAt={data.expectedUpdatedAt}\n expectedUpdatedAtOn={data.expectedUpdatedAtOn}\n />\n )\n const list = kayan.pages.list\n if (page.mode && list && 'page' in list) {\n return (\n <>\n {drawn(list)}\n {/* Named for a screen reader by the page's title, a create as new. */}\n <RecordFrame mode={page.mode} title={(data.value ? titleOf : newTitleOf)({ kayan, copy: { t, locale } })} closeLabel={t('admin.close', 'Close') ?? 'Close'} onClose={() => go(listHref)}>\n {form}\n </RecordFrame>\n </>\n )\n }\n return form\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 d96a3dd603b5aead8e3d82e8792fa40d5f4dac1bfbee2e7064ab2be888b595a6\n",
25
- "type": "registry:file",
26
- "target": "~/src/screens.tsx"
27
- },
28
- {
29
- "path": "registry/host/screen-form.tsx",
30
- "content": "import { useBlocker, useRouter } from '@tanstack/react-router'\nimport { useState, type ReactElement } from 'react'\nimport { Form, type FormProps } from './components/mithaq/form.tsx'\n\n/**\n * A kayan's form on a screen's page. A create moves to the new row's edit page, a delete to the list.\n * While the form holds a change the router blocks navigation and the form asks its discard question.\n */\nexport function ScreenForm({ edit, list, ...props }: Omit<FormProps, 'onDone' | 'onDirty' | 'onCancel' | 'blocked'> & {\n /** The href of a row's edit page, or nothing where the kayan has none. */\n edit: (id: string) => string | undefined\n /** The href of the kayan's list, or nothing where it has none. */\n list?: string\n}): ReactElement {\n const router = useRouter()\n const [dirty, setDirty] = useState(false)\n const blocker = useBlocker({ shouldBlockFn: () => dirty, withResolver: true, enableBeforeUnload: false })\n // `ignoreBlocker`: the form reports clean only on the render after the save.\n const go = (href: string | undefined) => void (href ? router.navigate({ href, ignoreBlocker: true }) : router.invalidate())\n const landing = (row: unknown, effect?: string) => {\n const id = (row as { id?: unknown } | null | undefined)?.id\n return effect === 'delete' ? list : effect === 'create' && id !== undefined ? edit(String(id)) : undefined\n }\n return (\n <Form\n {...props}\n onDirty={setDirty}\n onDone={(row, effect) => go(landing(row, effect))}\n onCancel={list ? () => go(list) : undefined}\n blocked={blocker.status === 'blocked' ? blocker : undefined}\n />\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 7284b5e97770fd0c395fafa0b7459cab4ceb458e4bc982dc8233894126116170\n",
31
- "type": "registry:file",
32
- "target": "~/src/screen-form.tsx"
33
- },
34
- {
35
- "path": "registry/host/screen-list.tsx",
36
- "content": "import { useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useState, type ReactElement } from 'react'\nimport { ListContext, useScreen } from 'mithaq/admin/react'\nimport { Button } from '@/components/ui/button'\nimport { List, type ListProps, type Page } from './components/mithaq/list.tsx'\nimport { QueryRefused, findOf, readQuery, readRaw, rulesOf, scopedTo, writeQuery, type ListQuery, type RawQuery } from './components/mithaq/list-query.ts'\nimport { counted } from './components/mithaq/controls.tsx'\nimport { listOf } from './components/mithaq/nodes.tsx'\n\n/**\n * The list's search as the router carries it: the URL's pairs as strings, each\n * `where[…]` pair a key of its own. The router's default would JSON-quote a\n * page number, so an app hands these two to `createRouter`.\n */\nexport const flatSearch = {\n parseSearch: (search: string) => Object.fromEntries(new URLSearchParams(search)),\n stringifySearch: (search: Record<string, unknown>) => {\n const written = new URLSearchParams(Object.entries(search).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]]))).toString()\n return written ? `?${written}` : ''\n },\n}\n\n/** A page route's search: the list's own keys, each the string the URL carries, read against the list once the loader has the kayan. */\nexport const askedFor = (search: Record<string, unknown>): RawQuery => readRaw(search)\n\n/** Every page of one kayan's list the browser holds, which a write to that kayan invalidates. */\nexport const rowsKey = (kayan: string) => ['mithaq', kayan] as const\n\n/**\n * Whether a list of this kayan reads with `draft: true`: the kayan keeps drafts\n * and this actor holds `findVersions`. Then never-published rows are listed,\n * each shown as its latest draft, and the total is counted the same way.\n */\nexport const readsDrafts = (kayan: Pick<ListProps['kayan'], 'drafts'>, operations?: Readonly<Record<string, boolean>>) =>\n !!kayan.drafts && operations?.findVersions === true\n\n/**\n * Whether this actor edits one row's drafts: it holds `findVersions`, and this row is within any limit on it.\n * A row left out gets a plain Save and no autosave, since the server answers a draft save on it 404.\n */\nexport const rowReadsDrafts = async ({ kayan, operations, id, findVersions }: {\n kayan: Pick<ListProps['kayan'], 'drafts'>\n operations?: Readonly<Record<string, boolean>>\n id?: string\n findVersions: (input: { where: { parent: { equals: string } }; limit: 1 }) => Promise<{ docs: readonly unknown[] } | null>\n}) => readsDrafts(kayan, operations) && (id === undefined || !!(await findVersions({ where: { parent: { equals: id } }, limit: 1 }))?.docs.length)\n\n/** A query the URL asks for, or the sentence it was refused with, which asks nothing of the server. */\nexport const queryOf = (props: Pick<ListProps, 'kayan' | 'client' | 'locales' | 'screen' | 'node'> & { maxLimit: number }, raw: RawQuery): { query: ListQuery } | { refused: string } => {\n try {\n return { query: readQuery(raw, rulesOf({ kayan: props.kayan, node: props.node ?? listOf(props.screen), maxLimit: props.maxLimit, context: { client: props.client, locales: props.locales } })) }\n } catch (thrown) {\n if (thrown instanceof QueryRefused) return { refused: thrown.message }\n throw thrown\n }\n}\n\n/**\n * One screen's list, over the URL and TanStack Query. The URL is the list's one\n * state; the server-rendered first page seeds the cache. A URL the list does\n * not offer is refused before any request.\n */\nexport function ScreenList({\n find,\n first,\n onQuery,\n own,\n props,\n raw,\n run,\n scope,\n}: {\n props: Omit<ListProps, 'page' | 'query' | 'onQuery' | 'run'>\n /** The URL's search, as the route validated it. */\n raw: RawQuery\n onQuery: (search: Record<string, string>) => void\n /** The page the loader read, and the input it read it with, which seeds the cache where it is the page this URL asks for. */\n first?: { input: unknown; rows: Page }\n find: (input: Record<string, unknown>, options?: { draft?: boolean }) => Promise<Page>\n run: (operation: string, input: Record<string, unknown>) => Promise<unknown>\n /** What a list node's own component draws in place of the registry's list, reading the same props through `useList()`. */\n own?: ReactElement\n /** The rows a list of another kayan is held to: its relationship holding the page's row. */\n scope?: Readonly<Record<string, unknown>>\n}): ReactElement | null {\n const queries = useQueryClient()\n // Rows the last bulk left alone: never-published drafts, which the list shows and a bulk does not write.\n const [left, setLeft] = useState(0)\n const asked = queryOf({ ...props, maxLimit: useScreen().maxLimit }, raw)\n const query = 'query' in asked ? asked.query : {}\n const found = findOf({ kayan: props.kayan, query, node: props.node ?? listOf(props.screen), locales: props.locales, client: props.client })\n const input = scope ? scopedTo(found, scope) : found\n const seeded = first && JSON.stringify(first.input) === JSON.stringify(input) ? first.rows : undefined\n const { data } = useQuery({\n queryKey: [...rowsKey(props.kayan.name), 'find', input],\n queryFn: () => find(input, readsDrafts(props.kayan, props.operations) ? { draft: true } : undefined),\n enabled: 'query' in asked,\n initialData: seeded,\n placeholderData: (previous) => previous ?? first?.rows,\n // Keeps the page the server just rendered from being read again as it\n // hydrates. An organization or actor switch that does not reload the\n // document must clear this cache, or the next actor sees the last one's pages.\n staleTime: 30_000,\n })\n if ('refused' in asked) {\n return (\n <div role=\"alert\" data-mithaq-refused={asked.refused} className=\"flex flex-col items-start gap-2 rounded-md border border-destructive/50 px-3 py-2 text-sm\">\n {props.t('admin.queryRefused', 'This link asks the list for something it does not offer, so nothing was read.')}\n <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => onQuery({})}>\n {props.t('admin.clearFilters', 'Clear filters')}\n </Button>\n </div>\n )\n }\n if (!data) return null\n const list: ListProps = {\n ...props,\n page: data,\n query,\n onQuery: (next) => onQuery(writeQuery(next)),\n run: async (operation, input) => {\n const written = await run(operation, input)\n setLeft((written as { errors?: readonly unknown[] } | null)?.errors?.length ?? 0)\n await queries.invalidateQueries({ queryKey: rowsKey(props.kayan.name) })\n return written\n },\n }\n return (\n <ListContext value={list}>\n {left ? (\n <p role=\"status\" data-mithaq-left={left} className=\"rounded-md border px-3 py-2 text-sm\">\n {counted({ t: props.t, locale: props.locale, key: 'admin.bulkLeft', count: left, fallback: '{count} of the ticked rows were left as they are, because they have never been published.' })}\n </p>\n ) : null}\n {own ?? <List {...list} />}\n </ListContext>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 3c37825f9f645fd3f9c95a3d1ec554e863e85767e8329a1b2e16a40e5f5ea381\n",
37
- "type": "registry:file",
38
- "target": "~/src/screen-list.tsx"
39
- },
40
- {
41
- "path": "registry/host/api.ts",
42
- "content": "import { afterCommitHeader, type ClientKayan, type FetchClient } from 'mithaq/admin'\n\n/** What a write may ask beside its input on a kayan that keeps drafts: a draft save, and an autosave. The fetch plug reads them off the query string only. */\nexport type DraftOptions = { readonly draft?: boolean; readonly autosave?: boolean }\n\nconst queryOf = ({ draft, autosave }: DraftOptions = {}) => (draft ? `?draft=true${autosave ? '&autosave=true' : ''}` : '')\n\nconst call = async ({ kayan, operation, input, ...options }: { kayan: string; operation: string; input: Record<string, unknown> } & DraftOptions) => {\n const response = await fetch(`/api/${encodeURIComponent(kayan)}/${encodeURIComponent(operation)}${queryOf(options)}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(input),\n })\n const answer = await response.json().catch(() => null)\n if (!response.ok) {\n const error = new Error(typeof answer?.message === 'string' ? answer.message : `HTTP ${response.status}`)\n Object.assign(error, { status: response.status, code: answer?.code, data: answer?.data })\n throw error\n }\n // A failed `afterCommit` raises the screens' banner and a committed write\n // clears it. Only the server knows an operation's effect, so the plug sets\n // the header on writes and never on reads.\n const said = response.headers.get(afterCommitHeader)\n if (typeof window !== 'undefined' && said) {\n window.dispatchEvent(new CustomEvent('mithaq:after-commit', { detail: said === 'failed' }))\n }\n return answer\n}\n\n// Restated, not imported from the `.tsx` file that declares them: a `.ts` file\n// must not need the JSX compiler.\ntype FoundPage = { docs: readonly Record<string, unknown>[]; hasNextPage: boolean; nextPage: number | null }\ntype FindApi = Readonly<\n Record<\n string,\n | {\n find: (input: Record<string, unknown>) => Promise<FoundPage>\n create?: (input: Record<string, unknown>) => Promise<unknown>\n delete?: (input: Record<string, unknown>) => Promise<unknown>\n update?: (input: Record<string, unknown>) => Promise<unknown>\n }\n | undefined\n >\n>\n\n/**\n * Every operation of every kayan, keyed by name strings. `arm[kayan][operation](input)`\n * posts to `POST /api/<kayan>/<operation>`; the proxy never answers `undefined`,\n * and a name no kayan has is the server's not-found.\n */\nexport type Arm = Readonly<Record<string, Readonly<Record<string, (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>>>>>\nexport const arm = new Proxy(Object.create(null) as Arm, {\n get: (_target, kayan: string) => new Proxy(Object.create(null) as Record<string, (input: Record<string, unknown>, options?: DraftOptions) => Promise<unknown>>, {\n get: (_operations, operation: string) => (input: Record<string, unknown>, options?: DraftOptions) => call({ kayan, operation, input, ...options }),\n }),\n})\n\n/**\n * Whether an operation's input names a key: probes the schema with a value no\n * browser-written schema accepts and looks for an issue at that key. A schema\n * that answers asynchronously, or throws, reads as naming nothing.\n */\nexport const takesKey = (input: { '~standard': { validate: (value: unknown) => unknown } }, key: string) => {\n let answer: { issues?: readonly { path?: readonly unknown[] }[] }\n try {\n answer = input['~standard'].validate({ [key]: Symbol(key) }) as typeof answer\n } catch {\n return false\n }\n if (answer instanceof Promise) {\n answer.catch(() => {})\n return false\n }\n return answer.issues?.some(({ path }) => {\n const segment = path?.[0]\n return String(typeof segment === 'object' && segment ? (segment as { key: unknown }).key : segment) === key\n }) ?? false\n}\n\n/** What a screen's `client` is over an arm; the draft words ride along with the input. */\nexport const clientOf = (through: Arm): FetchClient => ({ kayan, operation, input, draft, autosave }) => through[kayan]![operation]!(input, { draft, autosave })\n\n/**\n * What the server's `client` is: each operation in process, as `actor`. Only the\n * two draft words are read off the caller's options and the actor goes last, so\n * a loader cannot run as someone else.\n */\nexport const actingAs = (\n through: Readonly<Record<string, { operations: Record<string, (input: object, options: { actor?: unknown } & DraftOptions) => Promise<unknown>> }>>,\n actor: unknown,\n): FetchClient => ({ kayan, operation, input, draft, autosave }) => through[kayan]!.operations[operation]!(input, { draft, autosave, actor })\n\n/**\n * What a relationship control and cell read a second kayan through. Only the\n * kayans the screen's loader already narrowed to what this actor may read are\n * in the record, so a picker cannot page any other.\n */\nexport const readsOf = (kayans: readonly ClientKayan[]): FindApi =>\n Object.fromEntries(\n kayans.map((kayan) => [\n kayan.name,\n {\n find: (input: Record<string, unknown>) => arm[kayan.name]!.find!(input) as Promise<FoundPage>,\n },\n ]),\n )\n\ntype FieldReads = {\n readonly collections?: Readonly<\n Record<string, { readonly fields?: Readonly<Record<string, Readonly<Record<string, boolean>> | undefined>> } | undefined>\n >\n}\n\n/**\n * One kayan of the projection with every field this actor may not read taken\n * off it. A loader's result is serialized into the document, so an unreadable\n * field's name, label or rule would otherwise reach the viewer.\n */\nexport const readable = (one: ClientKayan, report: FieldReads): ClientKayan => ({\n ...one,\n fields: one.fields.filter((field) => report.collections?.[one.name]?.fields?.[field.name]?.read === true),\n})\n\n/**\n * Whether a thrown refusal is the caller's. Those carry an HTTP status; a store\n * that is down or a hook bug carries none and must be rethrown, not reported\n * as a missing row.\n */\nexport const caused = (thrown: unknown): boolean =>\n typeof (thrown as { status?: unknown } | null | undefined)?.status === 'number'\n\n/**\n * One POST at the auth arm (Better Auth's router under `/api/auth/user`) with the session cookie.\n * Returns the answer instead of throwing: a refusal is a sentence for the viewer; no reply is status 0.\n */\nexport const authArm = async (path: string, body: unknown = {}) => {\n const response = await fetch(`/api/auth/user/${path}`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(body),\n }).catch(() => null)\n if (!response) return { ok: false, status: 0, message: undefined, body: null }\n const answered = (await response.json().catch(() => null)) as ({ message?: string } & Record<string, unknown>) | null\n // The status too, because a rate limit's refusal carries no code to read.\n return { ok: response.ok, status: response.status, message: answered?.message, body: answered }\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 94db2fc49ad4391c0d1c2183cb41001dd08d62017c76af86cc5f611488c22c68\n",
43
- "type": "registry:file",
44
- "target": "~/src/api.ts"
45
- },
46
- {
47
- "path": "registry/host/slide.ts",
48
- "content": "/**\n * The provider's own session read, which refreshes a session past its `updateAge` and forwards the cookie.\n * Run it before the actor is resolved: a read that refreshes the row first leaves this one nothing due.\n */\nexport const slide = async ({\n strategies,\n headers,\n setResponseHeader,\n}: {\n strategies: readonly unknown[]\n headers: Headers\n setResponseHeader: (name: 'set-cookie', value: string[]) => void\n}) => {\n const cookies: string[] = []\n for (const strategy of strategies) {\n const read = (strategy as { server?: { api?: { getSession?: (args: object) => Promise<{ headers: Headers }> } } }).server?.api?.getSession\n if (read) cookies.push(...(await read({ headers, returnHeaders: true })).headers.getSetCookie())\n }\n // Set once with every strategy's cookies: a second set replaces the first.\n if (cookies.length) setResponseHeader('set-cookie', cookies)\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 bc0221e1088103f8638e0333b84aafb6b326e3991a187ced198bce2d090ecbe9\n",
49
- "type": "registry:file",
50
- "target": "~/src/slide.ts"
51
- },
52
- {
53
- "path": "registry/host/screen-names.ts",
54
- "content": "/**\n * The screen a request names, refused unless it is one of `known`'s own keys.\n * The input is whatever a request sent and it indexes `asas.targets`, so\n * `__proto__` and any other name no screen answers is refused before that read.\n */\nexport const screenNamed = <Known extends object>(known: Known, name: unknown): keyof Known & string => {\n if (typeof name !== 'string' || !Object.hasOwn(known, name)) throw new Error(`\"${String(name)}\" is no screen of this app.`)\n return name as keyof Known & string\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 878b7180f2aa169546feb42bcdfcb6002841d08e46788c94ddd6edfc4e3502ca\n",
55
- "type": "registry:file",
56
- "target": "~/src/screen-names.ts"
57
- },
58
- {
59
- "path": "registry/host/unauthorized.tsx",
60
- "content": "import { Fragment, useState, type ReactNode } from 'react'\nimport { createFileRoute, redirect } from '@tanstack/react-router'\nimport { Button } from '@/components/ui/button'\nimport { authArm } from '../api.ts'\nimport { AuthFrame, useRefusal } from '../auth-frame.tsx'\nimport { documentHead } from '../copy.ts'\nimport { ImpersonationBar, loadEnterable } from '../screens.tsx'\nimport { useCopy } from './__root.tsx'\n\nexport const Route = createFileRoute('/unauthorized')({\n // The path a screen refused; the server only matches it against the build's pages.\n validateSearch: (search: Record<string, unknown>): { from?: string } => (typeof search.from === 'string' ? { from: search.from } : {}),\n loaderDeps: ({ search }) => ({ from: search.from }),\n loader: async ({ deps, context }) => {\n const entered = await loadEnterable({ data: { from: deps.from, locale: context.locale } })\n // Carries this page's own path so a visitor whose session expired lands back here after signing in.\n if (!entered) throw redirect({ to: '/auth/sign-in', search: { redirect: '/unauthorized' } })\n return entered\n },\n // After the loader, so its data's type is inferred before this reads it.\n head: ({ match, loaderData }) => documentHead(match.context, (t) => (loaderData?.screens.length === 0 ? t('auth.noAccess') : t('auth.unauthorized'))),\n component: Unauthorized,\n})\n\nfunction Unauthorized() {\n const { t } = useCopy()\n const { screens, refused, email, impersonating, organizations = [], others = [] } = Route.useLoaderData()\n const refusal = useRefusal()\n const [switching, setSwitching] = useState<string>()\n const [failed, setFailed] = useState<string>()\n // The slug sits beside the name where another membership shares the name.\n const memberships = [...organizations, ...others]\n const named = (one: { id: string; name: string; slug?: string }): ReactNode =>\n one.slug && memberships.some((other) => other.id !== one.id && other.name === one.name) ? (\n <>\n {one.name} <span dir=\"ltr\" data-slug=\"\" className=\"text-muted-foreground text-xs font-normal\">{one.slug}</span>\n </>\n ) : (\n one.name\n )\n // Each name is isolated so an email or Latin name in an Arabic sentence keeps\n // its order, and is one inline box that wraps whole, so a long address never\n // runs past the card on a phone.\n const whole = 'inline-block max-w-full [overflow-wrap:anywhere]'\n const said = (text: string | undefined) =>\n text!.split(/(\\{email\\}|\\{organization\\}|\\{page\\})/).map((part, at) =>\n part === '{email}' ? <bdi key={at} className={whole}>{email}</bdi>\n : part === '{organization}' ? <bdi key={at}>{organizations.map((one, index) => <Fragment key={one.id}>{index ? ', ' : ''}<span className={whole}>{named(one)}</span></Fragment>)}</bdi>\n : part === '{page}' ? <bdi key={at}>{refused?.page}</bdi>\n : part)\n const link = 'text-foreground underline underline-offset-4'\n const screenName = (name: string) => t(`screens.${name}`, name) ?? name\n return (\n <AuthFrame\n title={screens.length ? t('auth.unauthorized', 'Not for your role') : t('auth.noAccess', 'Your account has no access yet')}\n description={\n <span data-no-access={screens.length ? undefined : ''}>\n {refused\n ? said(t('auth.unauthorizedPage', 'Your role does not open that page: {page}.'))\n : screens.length\n ? t('auth.unauthorizedDescription', 'Your role does not open that screen. These are the screens it opens:')\n : organizations.length\n ? said(t('auth.noAccessDescription', '{email} is a member of {organization} and holds no role that opens a screen here. Ask an administrator of {organization} to give you one.'))\n : said(t('auth.noOrganization', '{email} belongs to no organization yet. Ask an administrator to invite you.'))}\n </span>\n }\n >\n <div className=\"grid gap-4\">\n {/* Stopping impersonation is the way out in place of Sign out. */}\n {impersonating ? <ImpersonationBar name={impersonating} /> : null}\n {refused ? (\n <a className={link} href={refused.screen.path}>\n {(t('auth.backTo', 'Back to {screen}') ?? 'Back to {screen}').replace('{screen}', () => screenName(refused.screen.name))}\n </a>\n ) : screens.length ? (\n <ul className=\"grid gap-2\">\n {screens.map((screen) => (\n <li key={screen.name}>\n {/* An anchor: the path is the screen's own data, not a route the router knows. */}\n <a className={link} href={screen.path}>\n {screenName(screen.name)}\n </a>\n </li>\n ))}\n </ul>\n ) : null}\n {/* So a switch into an organization where the actor holds no role is not a dead end. */}\n {others.map((one) => (\n <Button\n key={one.id}\n type=\"button\"\n variant=\"secondary\"\n data-switch-organization={one.id}\n // Busy while it runs, so a second press sends nothing.\n disabled={switching !== undefined}\n aria-busy={switching === one.id || undefined}\n onClick={() => {\n setSwitching(one.id)\n setFailed(undefined)\n void authArm('organization/set-active', { organizationId: one.id }).then((answer) => {\n if (answer.ok) return window.location.assign('/auth/sign-in')\n setSwitching(undefined)\n setFailed(refusal(answer, t('auth.requestFailed', 'That request did not work.')))\n })\n }}\n >\n {/* One box, so the button's gap does not open beside the name. */}\n <span>{(t('auth.switchTo', 'Switch to {organization}') ?? 'Switch to {organization}').split(/(\\{organization\\})/).map((part, at) => (part === '{organization}' ? <bdi key={at}>{named(one)}</bdi> : part))}</span>\n </Button>\n ))}\n {failed ? <p role=\"alert\" className=\"text-destructive text-sm\">{failed}</p> : null}\n {/* `replace`, so Back does not return to the page signed out of. */}\n {impersonating ? null : (\n <Button type=\"button\" variant=\"outline\" className=\"max-sm:h-11\" onClick={() => void authArm('sign-out').then(() => window.location.replace('/auth/sign-in'))}>\n {t('admin.signOut', 'Sign out')}\n </Button>\n )}\n </div>\n </AuthFrame>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 3c5c304b3892c56759bf780f155ef3aa154ce3cc954e3d240b3537cdd97da735\n",
61
- "type": "registry:file",
62
- "target": "~/src/routes/unauthorized.tsx"
63
- },
64
- {
65
- "path": "registry/host/index.tsx",
66
- "content": "import { Link, createFileRoute } from '@tanstack/react-router'\nimport { BookOpen, LayoutDashboard, Languages, ShieldCheck, Users } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'\nimport { useHomeLabel } from '../auth-frame.tsx'\nimport { LocaleSwitch } from '../locale-switch.tsx'\nimport { loadEnterable } from '../screens.tsx'\nimport { useCopy } from './__root.tsx'\n\n// A host file because it reads `loadEnterable`, which only a host file may.\nexport const Route = createFileRoute('/')({\n loader: () => loadEnterable(),\n component: Landing,\n})\n\nfunction EntryActions({ screens, size }: { screens: readonly { name: string; path: string }[] | null; size: 'sm' | 'lg' }) {\n const { t } = useCopy()\n const label = useHomeLabel()\n if (screens === null) {\n return (\n <>\n <Link to=\"/auth/sign-up\">\n <Button size={size}>{t('marketing.getStarted', 'Get started')}</Button>\n </Link>\n <Link to=\"/auth/sign-in\">\n <Button size={size} variant=\"outline\">\n {t('auth.signIn', 'Sign in')}\n </Button>\n </Link>\n </>\n )\n }\n // A plain anchor: the path is the screen's own data, not a route the router knows.\n const home: { path: string; name?: string } = screens[0] ?? { path: '/unauthorized' }\n return (\n <a href={home.path}>\n <Button size={size}>{label(home, 'marketing.openScreen')}</Button>\n </a>\n )\n}\n\nfunction Feature({ icon: Icon, title, description }: { icon: typeof BookOpen; title: string; description: string }) {\n return (\n <Card>\n <CardHeader>\n <Icon className=\"mb-2 h-6 w-6 text-primary\" />\n <CardTitle className=\"text-base font-semibold\">{title}</CardTitle>\n </CardHeader>\n <CardContent className=\"text-sm text-muted-foreground\">{description}</CardContent>\n </Card>\n )\n}\n\nfunction Step({ number, title, description }: { number: number; title: string; description: string }) {\n return (\n <div className=\"flex flex-col gap-2\">\n <div className=\"flex h-9 w-9 items-center justify-center rounded-full bg-primary text-primary-foreground text-sm font-semibold\">\n {number}\n </div>\n <h3 className=\"font-semibold\">{title}</h3>\n <p className=\"text-sm text-muted-foreground\">{description}</p>\n </div>\n )\n}\n\nfunction Landing() {\n const { t, locale } = useCopy()\n const screens = Route.useLoaderData()?.screens ?? null\n // `t` answers `string | undefined` for a key no locale carries; the fallback only satisfies that type.\n const tt = (key: string, fallback: string) => t(key, fallback) ?? fallback\n const features = [\n { icon: BookOpen, title: tt('marketing.featureCoursesTitle', 'Courses and sessions'), description: tt('marketing.featureCoursesDescription', '') },\n { icon: Users, title: tt('marketing.featureEnrollmentsTitle', 'Enrollments'), description: tt('marketing.featureEnrollmentsDescription', '') },\n { icon: ShieldCheck, title: tt('marketing.featureRolesTitle', 'Roles and permissions'), description: tt('marketing.featureRolesDescription', '') },\n { icon: Languages, title: tt('marketing.featureBilingualTitle', 'Arabic and English, either direction'), description: tt('marketing.featureBilingualDescription', '') },\n { icon: LayoutDashboard, title: tt('marketing.featurePortalTitle', 'A dashboard and a customer portal'), description: tt('marketing.featurePortalDescription', '') },\n ]\n const steps = [\n { title: tt('marketing.howStep1Title', 'Set up your organization'), description: tt('marketing.howStep1Description', '') },\n { title: tt('marketing.howStep2Title', 'Add courses and roles'), description: tt('marketing.howStep2Description', '') },\n { title: tt('marketing.howStep3Title', 'Open the portal to your customers'), description: tt('marketing.howStep3Description', '') },\n ]\n return (\n <div className=\"flex min-h-screen flex-col\">\n <header className=\"sticky top-0 z-50 border-b bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60\">\n <div className=\"mx-auto flex h-16 max-w-6xl items-center justify-between gap-2 px-4 sm:gap-4 sm:px-6\">\n <span className=\"text-lg font-semibold\">{t('marketing.brand', 'Asas')}</span>\n <div className=\"flex items-center gap-1 sm:gap-2\">\n <LocaleSwitch locale={locale} />\n <EntryActions screens={screens} size=\"sm\" />\n </div>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <section className=\"mx-auto flex max-w-4xl flex-col items-center gap-6 px-4 py-20 text-center sm:px-6 sm:py-28\">\n <h1 className=\"text-4xl font-bold tracking-tight sm:text-5xl\">{t('marketing.heroTitle', 'Run courses and enrollments without the busywork')}</h1>\n <p className=\"max-w-2xl text-lg text-muted-foreground\">{t('marketing.heroSubtitle', '')}</p>\n <div className=\"flex flex-col gap-3 sm:flex-row\">\n <EntryActions screens={screens} size=\"lg\" />\n </div>\n </section>\n\n <section className=\"mx-auto max-w-6xl px-4 py-16 sm:px-6\">\n <div className=\"mx-auto mb-12 max-w-2xl text-center\">\n <h2 className=\"text-3xl font-bold tracking-tight\">{t('marketing.featuresTitle', 'Everything a training organization needs')}</h2>\n <p className=\"mt-3 text-muted-foreground\">{t('marketing.featuresSubtitle', '')}</p>\n </div>\n <div className=\"grid gap-6 sm:grid-cols-2 lg:grid-cols-3\">\n {features.map((feature) => (\n <Feature key={feature.title} {...feature} />\n ))}\n </div>\n </section>\n\n <section className=\"border-t bg-muted/30\">\n <div className=\"mx-auto max-w-6xl px-4 py-16 sm:px-6\">\n <h2 className=\"mb-10 text-center text-3xl font-bold tracking-tight\">{t('marketing.howTitle', 'How it works')}</h2>\n <div className=\"grid gap-8 sm:grid-cols-3\">\n {steps.map((step, at) => (\n <Step key={step.title} number={at + 1} {...step} />\n ))}\n </div>\n </div>\n </section>\n\n <section className=\"mx-auto flex max-w-3xl flex-col items-center gap-6 px-4 py-20 text-center sm:px-6\">\n <h2 className=\"text-3xl font-bold tracking-tight\">{t('marketing.ctaTitle', 'Start running your courses on Asas')}</h2>\n <p className=\"text-muted-foreground\">{t('marketing.ctaSubtitle', '')}</p>\n <EntryActions screens={screens} size=\"lg\" />\n </section>\n </main>\n\n <footer className=\"border-t py-8\">\n <div className=\"mx-auto max-w-6xl px-4 text-center text-sm text-muted-foreground sm:px-6\">\n {t('marketing.footer', 'Asas — courses and enrollments for training organizations.')}\n </div>\n </footer>\n </div>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 32a4513d4cf4a984b7845056965d48edff53542ce962b466af67a87983832a6d\n",
67
- "type": "registry:file",
68
- "target": "~/src/routes/index.tsx"
69
- },
70
- {
71
- "path": "registry/host/locale-switch.tsx",
72
- "content": "import { Button } from '@/components/ui/button'\nimport { localeEntries } from './components/mithaq/nav.tsx'\nimport { locales, switchLocale, type Locale } from './copy.ts'\n\n/** The language switch for pages without a form: `switchLocale` reloads without asking, so a page with a form must use the user menu, which asks first. */\nexport function LocaleSwitch({ locale }: { locale: Locale }) {\n return (\n <div className=\"flex items-center gap-1\">\n {localeEntries(locales, locale).map((entry) => (\n <Button\n key={entry.locale}\n type=\"button\"\n variant={entry.active ? 'secondary' : 'ghost'}\n size=\"xs\"\n aria-current={entry.active}\n aria-label={entry.label}\n onClick={() => switchLocale({ to: entry.locale })}\n >\n <span className=\"hidden sm:inline\">{entry.label}</span>\n <span className=\"sm:hidden\">{entry.locale.toUpperCase()}</span>\n </Button>\n ))}\n </div>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 43c6d309a5bae4f6af3cdcf4c741106b3a16129ac77d142f1e4254594f5b8a54\n",
73
- "type": "registry:file",
74
- "target": "~/src/locale-switch.tsx"
75
- },
76
- {
77
- "path": "registry/host/auth-frame.tsx",
78
- "content": "import { useState, type ComponentProps, type ReactNode } from 'react'\nimport { redirect, useRouteContext } from '@tanstack/react-router'\nimport { createServerFn } from '@tanstack/react-start'\nimport { Eye, EyeOff } from 'lucide-react'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'\nimport { authArm } from './api.ts'\nimport { FieldShell, describedBy } from './components/mithaq/field-shell.tsx'\nimport { refusalKey } from './copy.ts'\nimport { LocaleSwitch } from './locale-switch.tsx'\nimport { wanted } from './redirect.ts'\nimport { useCopy } from './routes/__root.tsx'\n\n/** The one search key the auth pages read, off the raw search. */\nexport const redirectSearch = (search: Record<string, unknown>): { redirect?: string } =>\n typeof search.redirect === 'string' ? { redirect: search.redirect } : {}\n\n/** Whether a read of the actor names someone. A read that throws, or an actor with no email, names no one. */\nexport const signedInAs = async (actor: Promise<unknown>) => Boolean(((await actor.catch(() => null)) as { email?: unknown } | null)?.email)\n\n/**\n * Whether this request carries a session, where signing in lands for it, and the provider's password bounds.\n * No auth page sends a referrer, since the reset page's address carries its token.\n */\nexport const authGate = createServerFn({ method: 'GET' }).handler(async () => {\n // Imported inside the server half so no auth page ships the screen host to the browser.\n const [{ getRequest, setResponseHeader }, { asas }, { loadEnterable }] = await Promise.all([import('@tanstack/react-start/server'), import('asas'), import('./screens.tsx')])\n setResponseHeader('referrer-policy', 'no-referrer')\n type Provider = { name: string; server: { $context: Promise<{ password: { config: { minPasswordLength: number; maxPasswordLength: number } } }> } }\n const provider = (asas.auth.strategies as unknown as Provider[]).find((one) => one.name === 'user')!\n const [signedIn, { password }] = await Promise.all([signedInAs(asas.auth.actor(getRequest().headers)), provider.server.$context])\n const first = signedIn ? (await loadEnterable())?.screens[0] : undefined\n const home: { path: string; name?: string } = first ?? { path: '/unauthorized' }\n return { signedIn, home, password: { min: password.config.minPasswordLength, max: password.config.maxPasswordLength } }\n})\n\n/**\n * Sign-in's and sign-up's `beforeLoad`. A signed-in person is sent where a\n * successful sign-in sends them; everyone else gets the password bounds in the\n * route context.\n */\nexport const gate = async ({ search }: { search: { redirect?: string } }) => {\n const { signedIn, home, password } = await authGate()\n if (signedIn) throw redirect({ href: wanted({ asked: search.redirect, home: home.path }) })\n return { password }\n}\n\n/** What a link to `home` says: the screen it opens, or, where it opens none, the action of seeing the account's status. */\nexport const useHomeLabel = () => {\n const { t } = useCopy()\n return (home: { name?: string }, sentence: 'auth.backTo' | 'marketing.openScreen') =>\n home.name === undefined\n ? t('auth.accountStatus', 'See your account status')\n : t(sentence, sentence === 'auth.backTo' ? 'Back to {screen}' : 'Open {screen}')!.replace('{screen}', () => t(`screens.${home.name}`, home.name)!)\n}\n\n/** What the forgot and reset pages draw for someone already signed in: the way on, and a sign-out. */\nexport function AlreadySignedIn({ redirect: asked, home }: { redirect?: string; home: { path: string; name?: string } }) {\n const { t } = useCopy()\n const label = useHomeLabel()\n const to = wanted({ asked, home: home.path })\n return (\n <AuthFrame title={t('auth.alreadySignedIn', 'You are already signed in')}>\n <div className=\"grid gap-4\">\n {/* An anchor: the path is the visitor's own, not a route the router knows. */}\n <a className=\"text-primary text-center text-sm underline underline-offset-4\" href={to}>\n {to === home.path ? label(home, 'auth.backTo') : t('auth.continue', 'Continue')}\n </a>\n <Button type=\"button\" variant=\"outline\" onClick={() => void authArm('sign-out').then(() => window.location.reload())}>\n {t('admin.signOut', 'Sign out')}\n </Button>\n </div>\n </AuthFrame>\n )\n}\n\n/** The sentence for a refusal of the provider: this app's own words for the status or code, then `fallback`. Never the provider's `message`, which is English in any locale. */\nexport const useRefusal = () => {\n const { t } = useCopy()\n return (answered: { status: number; body: Record<string, unknown> | null }, fallback: string | undefined) => {\n const key = refusalKey(answered.status, answered.body?.code)\n return (key && t(key)) || fallback || ''\n }\n}\n\n/** Whether the browser's own `type=\"email\"` refuses this text. */\nexport const notAnEmail = (value: string) => Object.assign(document.createElement('input'), { type: 'email', value }).validity.typeMismatch\n\n/** The frame every auth page is drawn in: the app's name linking home, the language switch, and the page's card. */\nexport function AuthFrame({ title, description, children }: { title: string | undefined; description?: ReactNode; children: ReactNode }) {\n const { t, locale } = useCopy()\n const { app } = useRouteContext({ from: '__root__' })\n return (\n <div className=\"flex min-h-screen flex-col\">\n <header className=\"mx-auto flex h-16 w-full max-w-6xl items-center justify-between gap-2 px-4 sm:px-6\">\n {/* An anchor: `/` is not a router route in an app whose start entry answers its home. */}\n <a className=\"text-lg font-semibold\" href=\"/\">\n {t(app, 'Asas')}\n </a>\n <LocaleSwitch locale={locale} />\n </header>\n <main className=\"flex flex-1 items-center justify-center px-4 pb-16\">\n <Card className=\"w-full max-w-sm\">\n <CardHeader>\n <CardTitle><h1>{title}</h1></CardTitle>\n {description ? <CardDescription>{description}</CardDescription> : null}\n </CardHeader>\n {/* Descriptions follow the text direction; the shared primitive aligns them left. */}\n <CardContent className=\"[&_[data-slot=field-description]]:text-start\">{children}</CardContent>\n </Card>\n </main>\n </div>\n )\n}\n\nfunction PasswordInput(props: ComponentProps<'input'>) {\n const { t } = useCopy()\n const [shown, setShown] = useState(false)\n return (\n // Focus moving to the box's own toggle has not left the box.\n <InputGroup onBlur={(event) => event.currentTarget.contains(event.relatedTarget) && event.stopPropagation()}>\n <InputGroupInput {...props} dir=\"ltr\" type={shown ? 'text' : 'password'} />\n <InputGroupAddon align=\"inline-end\">\n <InputGroupButton aria-label={t('auth.showPassword', 'Show password')} aria-pressed={shown} size=\"icon-xs\" onClick={() => setShown((was) => !was)}>\n {shown ? <EyeOff /> : <Eye />}\n </InputGroupButton>\n </InputGroupAddon>\n </InputGroup>\n )\n}\n\n/** One labelled box, which says what is wrong with it once it has been left. An email or password reads left to right in any locale. */\nexport function AuthBox({\n id,\n label,\n error,\n description,\n onLeave,\n ...props\n}: { id: string; label: string | undefined; error?: string; description?: string; onLeave: () => void } & ComponentProps<'input'>) {\n const control = { 'aria-describedby': describedBy({ id, description, error }), 'aria-invalid': error ? true : undefined, id, name: id, required: true, ...props }\n return (\n <FieldShell id={id} label={label ?? id} description={description} error={error} onBlur={onLeave}>\n {props.type === 'password' ? <PasswordInput {...control} /> : <Input dir={props.type === 'email' ? 'ltr' : 'auto'} {...control} />}\n </FieldShell>\n )\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 ab822afe361dfc5f82c504570fd5d0ea13ec4572e60adb679a3f9532ccf67803\n",
79
- "type": "registry:file",
80
- "target": "~/src/auth-frame.tsx"
81
- },
82
- {
83
- "path": "registry/host/links.ts",
84
- "content": "/**\n * The path a click on a plain `<a href>` is routed to inside the screen, or\n * `undefined` for a click the browser keeps: a modified or handled click, another\n * target, a download, another origin, or a path outside the screen's `base`.\n */\nexport const routedHref = ({\n event,\n origin,\n base,\n}: {\n event: Pick<MouseEvent, 'button' | 'metaKey' | 'altKey' | 'ctrlKey' | 'shiftKey' | 'defaultPrevented' | 'target'>\n origin: string\n base: string\n}): string | undefined => {\n if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.altKey || event.ctrlKey || event.shiftKey) return undefined\n const anchor = (event.target as Element | null)?.closest?.('a[href]')\n const target = anchor?.getAttribute('target')\n if (!anchor || (target && target !== '_self') || anchor.hasAttribute('download')) return undefined\n // The browser's own resolution against the current page: against the origin\n // alone, `href=\"edit\"` on `/dashboard/courses/` would read as `/edit`.\n const url = new URL((anchor as HTMLAnchorElement).href)\n if (url.origin !== origin || (url.pathname !== base && !url.pathname.startsWith(`${base}/`))) return undefined\n return `${url.pathname}${url.search}${url.hash}`\n}\n\n/**\n * Where a list asked for a page past its last is sent instead: the same URL at\n * the last page, or with no page where the last is the first. `undefined` for a\n * page in range.\n */\nexport const clampedHref = ({ href, page, totalPages }: { href: string; page: number; totalPages: number }): string | undefined => {\n if (page <= Math.max(1, totalPages)) return undefined\n const url = new URL(href, 'http://local')\n if (totalPages > 1) url.searchParams.set('page', String(totalPages))\n else url.searchParams.delete('page')\n return `${url.pathname}${url.search}${url.hash}`\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 4754cbac64eeac528e7e037173ad56ffa8d07df3e526e1191bbe3be400db97a5\n",
85
- "type": "registry:file",
86
- "target": "~/src/links.ts"
87
- },
88
- {
89
- "path": "registry/host/request-headers.ts",
90
- "content": "/**\n * The headers a page's server-side `request` call sends: the session's, with\n * the loader's own layered over them. The session's cookie always wins, so a\n * loader's headers cannot act as another actor.\n */\nexport const mergedHeaders = (session: Headers, own?: HeadersInit): Headers => {\n const headers = new Headers(session)\n if (own) for (const [key, value] of new Headers(own)) if (key !== 'cookie') headers.set(key, value)\n return headers\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 2969a3dafbf201d6eb6074ecda0e8f17e341cbd1ca176100350844e74ec23623\n",
91
- "type": "registry:file",
92
- "target": "~/src/request-headers.ts"
93
- }
94
- ],
95
- "type": "registry:item"
96
- }
@@ -1,44 +0,0 @@
1
- {
2
- "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
- "name": "mithaq-storage",
4
- "title": "Mithaq storage",
5
- "description": "The storage adapters a project owns, S3 with an R2 preset, UploadThing for reading and deleting files already there, and a local folder, and the eight functions the storage rukn's config names: the object key, the verification of a stored file, its disposition, the sweep of orphans, and the upload flow's instructions, create step, delete step and file route.",
6
- "dependencies": [
7
- "aws4fetch@1.0.20",
8
- "file-type@22.0.1",
9
- "image-dimensions@2.5.1"
10
- ],
11
- "files": [
12
- {
13
- "path": "registry/storage/s3.ts",
14
- "content": "// An S3 bucket as a mithaq storage adapter, over aws4fetch, so it runs on Node, Bun and\n// Workers alike and reaches AWS S3, Cloudflare R2, MinIO and Backblaze B2 the same way.\nimport { AwsClient } from 'aws4fetch'\nimport type { StorageAdapter } from 'mithaq'\n\nexport type S3Options = {\n /** The service's address without the bucket, such as `https://s3.eu-west-1.amazonaws.com`. */\n endpoint: string\n bucket: string\n region: string\n accessKeyId: string\n secretAccessKey: string\n /**\n * The bucket folder this project owns, such as `mithaq/asas`, with no slash at either end.\n * The sweep lists nothing outside it, so a bucket other apps share keeps their objects.\n */\n prefix: string\n /** Where the bucket is read publicly, for a kayan that writes `public: true`. */\n publicURL?: string\n}\n\nconst putSeconds = 600\nconst getSeconds = 300\n\nconst pathOf = (key: string) => key.split('/').map(encodeURIComponent).join('/')\n\nconst entities: Record<string, string> = { amp: '&', lt: '<', gt: '>', quot: '\"', apos: \"'\" }\nconst text = (xml: string, tag: string) => xml.match(new RegExp(`<${tag}>([\\\\s\\\\S]*?)</${tag}>`))?.[1]?.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => entities[name]!)\n\n/**\n * The S3 adapter: uploads are signed PUTs bound to the declared type and size, and downloads\n * are 302s to a signed GET that carries the project's disposition.\n */\nexport const s3 = ({ endpoint, bucket, region, accessKeyId, secretAccessKey, prefix, publicURL }: S3Options): StorageAdapter => {\n if (!prefix || prefix.startsWith('/') || prefix.endsWith('/') || prefix.split('/').some((part) => !part || part === '.' || part === '..')) {\n throw new Error(`The S3 adapter's prefix ${JSON.stringify(prefix)} names no folder of the bucket. Name one, such as \"mithaq/asas\".`)\n }\n const client = new AwsClient({ accessKeyId, secretAccessKey, service: 's3', region })\n const base = `${endpoint.replace(/\\/+$/, '')}/${bucket}`\n const urlOf = (key: string) => `${base}/${pathOf(key)}`\n const signed = async (url: string, init: { method: string; headers?: Record<string, string> }) =>\n (await client.sign(url, { ...init, aws: { signQuery: true, allHeaders: true } })).url\n return {\n prefix,\n uploadInstructions: {\n // Signing the type and size makes the bucket refuse any other body, and signing\n // `If-None-Match: *` makes it refuse a second PUT rather than replace a verified object.\n generate: async ({ filesize, mimeType, key }) => {\n const headers = { 'Content-Type': mimeType, 'Content-Length': String(filesize), 'If-None-Match': '*' }\n return { type: 'http', request: { method: 'PUT', url: await signed(`${urlOf(key)}?X-Amz-Expires=${putSeconds}`, { method: 'PUT', headers }), headers } }\n },\n },\n handleDelete: async ({ key }) => {\n const answered = await client.fetch(urlOf(key), { method: 'DELETE' })\n if (!answered.ok && answered.status !== 404) throw new Error(`S3 refused to delete an object: ${answered.status}.`)\n },\n generateURL: ({ key }) => {\n if (!publicURL) throw new Error('This S3 adapter was given no publicURL, so it has no public address for a file.')\n return `${publicURL.replace(/\\/+$/, '')}/${pathOf(key)}`\n },\n staticHandler: async (_request, { doc, disposition }) => {\n const query = new URLSearchParams({ 'X-Amz-Expires': String(getSeconds), 'response-content-disposition': disposition, 'response-content-type': doc.mimeType })\n return new Response(null, { status: 302, headers: { Location: await signed(`${urlOf(doc.key)}?${query}`, { method: 'GET' }) } })\n },\n head: async ({ key }) => {\n const answered = await client.fetch(urlOf(key), { method: 'HEAD' })\n if (answered.status === 404) return null\n if (!answered.ok) throw new Error(`S3 answered a HEAD with ${answered.status}.`)\n return { size: Number(answered.headers.get('content-length')) }\n },\n readHead: async ({ key, bytes }) => {\n const answered = await client.fetch(urlOf(key), { headers: { Range: `bytes=0-${bytes - 1}` } })\n if (!answered.ok) throw new Error(`S3 answered a ranged GET with ${answered.status}.`)\n return new Uint8Array(await answered.arrayBuffer()).subarray(0, bytes)\n },\n list: async function* () {\n let token: string | undefined\n do {\n const query = new URLSearchParams({ 'list-type': '2', prefix: `${prefix}/`, ...(token && { 'continuation-token': token }) })\n const answered = await client.fetch(`${base}?${query}`)\n if (!answered.ok) throw new Error(`S3 answered a list with ${answered.status}.`)\n const xml = await answered.text()\n for (const [, one] of xml.matchAll(/<Contents>([\\s\\S]*?)<\\/Contents>/g)) {\n yield { key: text(one!, 'Key')!, lastModified: new Date(text(one!, 'LastModified')!) }\n }\n token = text(xml, 'IsTruncated') === 'true' ? text(xml, 'NextContinuationToken') : undefined\n } while (token)\n },\n }\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 a6b1f7b3a13ef66fb811f35844279694bfb8f3d08657170fa98e882bc0f5e079\n",
15
- "type": "registry:lib",
16
- "target": "~/src/storage/s3.ts"
17
- },
18
- {
19
- "path": "registry/storage/r2.ts",
20
- "content": "import { s3, type S3Options } from './s3.ts'\n\nexport const r2 = ({ accountId, ...rest }: Omit<S3Options, 'endpoint' | 'region'> & { accountId: string }) =>\n s3({ ...rest, endpoint: `https://${accountId}.r2.cloudflarestorage.com`, region: 'auto' })\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 3f592d20f1b51223f76dba6cc10eb9903abcf5084fe8bec1424e5f929769acff\n",
21
- "type": "registry:lib",
22
- "target": "~/src/storage/r2.ts"
23
- },
24
- {
25
- "path": "registry/storage/uploadthing.ts",
26
- "content": "// UploadThing as a mithaq storage adapter for files already stored there: it reads, signs a\n// short-lived address and deletes by key, and never uploads, so new files go to another adapter.\nimport type { StorageAdapter } from 'mithaq'\n\nexport type UploadThingOptions = {\n /** `UPLOADTHING_TOKEN`, the base64 JSON that names the API key and the app. */\n token: string\n /** The fetch every request goes through, which a test replaces. */\n fetch?: typeof fetch\n}\n\nconst getSeconds = 300\nconst api = 'https://api.uploadthing.com/v6'\n\nexport const uploadthing = ({ token, fetch: send = fetch }: UploadThingOptions): StorageAdapter => {\n const { apiKey, appId } = JSON.parse(Buffer.from(token, 'base64').toString()) as { apiKey: string; appId: string }\n const fileURL = (key: string) => `https://${appId}.ufs.sh/f/${encodeURIComponent(key)}`\n const call = async (path: string, body: unknown) => {\n const answered = await send(`${api}/${path}`, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json', 'x-uploadthing-api-key': apiKey },\n body: JSON.stringify(body),\n })\n if (!answered.ok) throw new Error(`UploadThing answered ${path} with ${answered.status}.`)\n return answered.json() as Promise<Record<string, unknown>>\n }\n return {\n // UploadThing chose these keys and lists none of them, so no sweep ever reaches them.\n prefix: '',\n uploadInstructions: {\n generate: async () => {\n throw new Error('The UploadThing adapter reads and deletes the files already there, and uploads none. Send new uploads to another adapter.')\n },\n },\n handleDelete: async ({ key }) => {\n await call('deleteFiles', { fileKeys: [key] })\n },\n generateURL: ({ key }) => fileURL(key),\n // UploadThing's signed address takes no disposition, so the stored type stands and an SVG\n // stored there is served to draw, not to download.\n staticHandler: async (_request, { doc }) => {\n const { url } = await call('requestFileAccess', { fileKey: doc.key, expiresIn: getSeconds })\n if (typeof url !== 'string') throw new Error('UploadThing answered requestFileAccess with no url.')\n return new Response(null, { status: 302, headers: { Location: url } })\n },\n head: async ({ key }) => {\n const answered = await send(fileURL(key), { method: 'HEAD' })\n if (answered.status === 404) return null\n if (!answered.ok) throw new Error(`UploadThing answered a HEAD with ${answered.status}.`)\n return { size: Number(answered.headers.get('content-length')) }\n },\n readHead: async ({ key, bytes }) => {\n const answered = await send(fileURL(key), { headers: { Range: `bytes=0-${bytes - 1}` } })\n if (!answered.ok) throw new Error(`UploadThing answered a ranged GET with ${answered.status}.`)\n return new Uint8Array(await answered.arrayBuffer()).subarray(0, bytes)\n },\n }\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 edb513da5b7d5ee1a405e8943b00bd25fa0b1cbf0c6c3d1a2854deaea95b8564\n",
27
- "type": "registry:lib",
28
- "target": "~/src/storage/uploadthing.ts"
29
- },
30
- {
31
- "path": "registry/storage/local.ts",
32
- "content": "// A folder on this machine as a mithaq storage adapter, for development and CI. A folder has\n// no signed URLs, so an upload is a PUT to `<route>/<token>`, the token signed for one key,\n// type and size; the bytes become the object only when their count is exactly the size signed.\nimport { createHmac, randomUUID, timingSafeEqual } from 'node:crypto'\nimport { createReadStream } from 'node:fs'\nimport { link, mkdir, open, readdir, rm, stat } from 'node:fs/promises'\nimport { dirname, relative, resolve, sep } from 'node:path'\nimport { Readable } from 'node:stream'\nimport type { StorageAdapter } from 'mithaq'\n\nexport type LocalOptions = {\n dir: string\n /** What the upload tokens are signed with, 32 characters at least. */\n secret: string\n /** The folder under `dir` this project's keys start with, and the one the sweep lists. */\n prefix: string\n /** The path the upload route answers under, `/api/storage/local` unless named. */\n route?: string\n}\n\nconst tokenSeconds = 600\n\ntype Token = { key: string; type: string; size: number; exp: number }\n\nconst refused = (status: number, message: string) => Response.json({ message }, { status })\n\n/**\n * The local adapter. A key that leaves `dir`, or holds an empty, `.` or `..` part, a backslash\n * or a NUL, is refused before any file is touched, whoever handed it in.\n */\nexport const local = ({ dir, secret, prefix, route = '/api/storage/local' }: LocalOptions): StorageAdapter => {\n if (secret.length < 32) throw new Error('The local storage adapter signs its uploads with a secret of 32 characters at least.')\n if (!prefix || prefix.split('/').some((part) => !part || part === '.' || part === '..')) {\n throw new Error(`The local storage adapter's prefix ${JSON.stringify(prefix)} names no folder. Name one, such as \"mithaq/asas\".`)\n }\n const root = resolve(dir)\n const pathOf = (key: string) => {\n if (typeof key !== 'string' || !key || /[\\\\\\0]/.test(key) || key.split('/').some((part) => part === '' || part === '.' || part === '..')) {\n throw new Error(`The key ${JSON.stringify(key)} names no file inside the storage folder.`)\n }\n const full = resolve(root, key)\n if (!full.startsWith(root + sep)) throw new Error(`The key ${JSON.stringify(key)} names no file inside the storage folder.`)\n return full\n }\n const sign = (body: string) => createHmac('sha256', secret).update(body).digest('base64url')\n const tokenOf = (token: Token) => {\n const body = Buffer.from(JSON.stringify(token)).toString('base64url')\n return `${body}.${sign(body)}`\n }\n const held = (token: string): Token | undefined => {\n const [body = '', signature = ''] = token.split('.')\n const given = Buffer.from(signature)\n const expected = Buffer.from(sign(body))\n if (given.length !== expected.length || !timingSafeEqual(given, expected)) return undefined\n const read = JSON.parse(Buffer.from(body, 'base64url').toString()) as Token\n return read.exp * 1000 < Date.now() ? undefined : read\n }\n return {\n prefix,\n uploadInstructions: {\n generate: async ({ filesize, mimeType, key }) => {\n pathOf(key)\n const token = tokenOf({ key, type: mimeType, size: filesize, exp: Math.floor(Date.now() / 1000) + tokenSeconds })\n return { type: 'http', request: { method: 'PUT', url: `${route}/${token}`, headers: { 'Content-Type': mimeType } } }\n },\n },\n handler: async (request) => {\n const { pathname } = new URL(request.url)\n if (request.method !== 'PUT' || !pathname.startsWith(`${route}/`)) return null\n const token = held(pathname.slice(route.length + 1))\n if (!token) return refused(403, 'The upload token is not one this server signed, or it has expired.')\n if (request.headers.get('content-type') !== token.type) return refused(400, 'The upload is not of the type that was signed.')\n let path: string\n try {\n path = pathOf(token.key)\n } catch {\n return refused(400, 'The upload names no file inside the storage folder.')\n }\n await mkdir(dirname(path), { recursive: true })\n const partial = `${path}.${randomUUID()}.part`\n const file = await open(partial, 'wx')\n let written = 0\n try {\n try {\n const reader = request.body?.getReader()\n while (reader) {\n const { done, value } = await reader.read()\n if (done) break\n written += value.byteLength\n if (written > token.size) return refused(400, 'The upload is larger than the size that was signed.')\n await file.write(value)\n }\n } finally {\n await file.close()\n }\n if (written !== token.size) return refused(400, 'The upload is not the size that was signed.')\n // A second PUT with the same token must not change an object its create already\n // verified: a link fails where the object exists, where a rename would replace it.\n try {\n await link(partial, path)\n } catch (error) {\n if ((error as { code?: unknown }).code === 'EEXIST') return refused(409, 'This upload was already sent.')\n throw error\n }\n return new Response(null, { status: 204 })\n } finally {\n await rm(partial, { force: true })\n }\n },\n handleDelete: async ({ key }) => {\n await rm(pathOf(key), { force: true })\n },\n staticHandler: async (_request, { doc }) => {\n const path = pathOf(doc.key)\n const { size } = await stat(path)\n return new Response(Readable.toWeb(createReadStream(path)) as ReadableStream, { status: 200, headers: { 'Content-Length': String(size) } })\n },\n head: async ({ key }) => {\n const found = await stat(pathOf(key)).catch(() => undefined)\n return found?.isFile() ? { size: found.size } : null\n },\n readHead: async ({ key, bytes }) => {\n const file = await open(pathOf(key))\n try {\n const buffer = new Uint8Array(bytes)\n const { bytesRead } = await file.read(buffer, 0, bytes, 0)\n return buffer.subarray(0, bytesRead)\n } finally {\n await file.close()\n }\n },\n // The prefix's folder alone, so another project sharing `dir` keeps its files.\n list: async function* () {\n const entries = await readdir(resolve(root, prefix), { recursive: true, withFileTypes: true }).catch(() => [])\n for (const entry of entries) {\n if (!entry.isFile()) continue\n const path = resolve(entry.parentPath, entry.name)\n yield { key: relative(root, path).split(sep).join('/'), lastModified: (await stat(path)).mtime }\n }\n },\n }\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 86a4471247d69e5f9d5427bb88decdb6d05a0fe5b7252ea88b47e37484dd510c\n",
33
- "type": "registry:lib",
34
- "target": "~/src/storage/local.ts"
35
- },
36
- {
37
- "path": "registry/storage/defaults.ts",
38
- "content": "import { randomUUID } from 'node:crypto'\nimport { fileTypeFromBuffer } from 'file-type'\nimport { imageDimensionsFromData } from 'image-dimensions'\nimport {\n adapterOf,\n allowedType,\n checkReference,\n fileHeaders,\n invalid,\n readForFile,\n sanitizeFilename,\n signReference,\n type StorageConfig,\n type StorageKayan,\n} from 'mithaq'\n\n/**\n * `<prefix>/<tenant>/<kayan>/<id>/<filename>`, under the adapter's prefix, the\n * one folder its sweep lists. The id is the row's own, so two files of one name never meet.\n */\nexport const defaultKey: StorageConfig['key'] = ({ prefix, tenant, kayan, id, filename }) =>\n [...(prefix ? [prefix] : []), (tenant ?? '_').replaceAll('/', '_'), kayan, id, filename].join('/')\n\n/** The bytes `file-type` reads to tell a type, its own `reasonableDetectionSizeInBytes`. */\nconst sniffed = 4100\n\n/**\n * Checks the stored object is there, of the declared size and no larger than the kayan takes,\n * and of the declared type as its first bytes tell it, and reads an image's width and height.\n * A type the bytes cannot tell, such as text/plain, CSV or JSON, is refused; SVG is the one text type taken.\n */\nexport const defaultVerify: StorageConfig['verify'] = async ({ adapter, key, mimeType, size, mimeTypes, fileSize }) => {\n const head = await adapter.head({ key })\n if (!head) return { refused: 'No file was uploaded.' }\n if (head.size !== size) return { refused: `The file holds ${head.size} bytes, and ${size} were declared.` }\n if (head.size > fileSize) return { refused: `The file holds more than ${fileSize} bytes.` }\n const bytes = await adapter.readHead({ key, bytes: sniffed })\n const read = (await fileTypeFromBuffer(bytes))?.mime\n const svg = (!read || read === 'application/xml') && /<svg[\\s>]/i.test(new TextDecoder().decode(bytes))\n const type = svg ? 'image/svg+xml' : read\n if (type !== mimeType) return { refused: `The file is ${type ?? 'of no type a file can be told by'}, and ${mimeType} was declared.` }\n if (!allowedType(type, mimeTypes)) return { refused: `The file is ${type}, which this kayan does not hold.` }\n const measured = imageDimensionsFromData(bytes)\n return { width: measured?.width ?? null, height: measured?.height ?? null }\n}\n\n/** The types a browser draws in place and runs nothing in. Everything else is downloaded. */\nconst drawn = /^(image\\/(png|jpeg|gif|webp|avif)|application\\/pdf)$/\n\n/**\n * `inline` for a raster image and a PDF, `attachment` for every other type, an SVG among them,\n * with the uploaded name (RFC 6266: `filename` for old clients, `filename*` for the rest).\n */\nexport const defaultDisposition: StorageConfig['disposition'] = ({ filename, mimeType }) => {\n const plain = filename.replace(/[^\\x20-\\x7e]|[\"\\\\]/g, '_')\n const encoded = encodeURIComponent(filename).replace(/['()*]/g, (one) => `%${one.charCodeAt(0).toString(16).toUpperCase()}`)\n return `${drawn.test(mimeType) ? 'inline' : 'attachment'}; filename=\"${plain}\"; filename*=UTF-8''${encoded}`\n}\n\n/** How long an object may sit with no row naming it before it is swept: an upload in flight has an hour. */\nconst day = 24 * 60 * 60 * 1000\n\n/** Removes every object older than a day that no row names, or with `check` lists them and leaves them. */\nexport const defaultSweep: StorageConfig['sweep'] = async ({ adapter, named, check, now }) => {\n const orphans: string[] = []\n for await (const { key, lastModified } of adapter.list!()) {\n if (named.has(key) || now.getTime() - lastModified.getTime() < day) continue\n orphans.push(key)\n if (!check) await adapter.handleDelete({ key })\n }\n return orphans\n}\n\n/**\n * Answers `uploadInstructions` once core has checked the create permission: validates the request,\n * then returns the row's id, the reference core signs for it and the adapter's signed request.\n */\nexport const defaultUploadInstructions: StorageConfig['instructions'] = async ({ kayan, input, actor }) => {\n const asked = input as Record<string, unknown> | null\n if (\n !asked || typeof asked !== 'object' || Array.isArray(asked)\n || typeof asked.filename !== 'string' || asked.filename.length > 255\n || typeof asked.mimeType !== 'string' || !/^[\\w.+-]+\\/[\\w.+-]+$/.test(asked.mimeType)\n || !Number.isSafeInteger(asked.filesize) || (asked.filesize as number) < 1\n ) {\n throw invalid({ message: 'An upload names its filename, 255 characters at most, its mimeType, a type such as image/png, and its filesize, a whole number of bytes above 0.', key: 'error.invalid.upload_request', params: { kayan: kayan.name } })\n }\n const filename = sanitizeFilename(asked.filename as string)\n const mimeType = asked.mimeType as string\n const filesize = asked.filesize as number\n if (!filename) throw invalid({ message: 'The file has no name left once its folders and control characters are dropped.', key: 'error.invalid.upload_request', params: { kayan: kayan.name } })\n if (!allowedType(mimeType, kayan.storage.mimeTypes)) {\n throw invalid({ message: `A \"${kayan.name}\" file may not be of type \"${mimeType}\".`, key: 'error.invalid.upload_type', params: { kayan: kayan.name, type: mimeType } })\n }\n if (filesize > kayan.storage.fileSize) {\n throw invalid({ message: `A \"${kayan.name}\" file holds ${kayan.storage.fileSize} bytes at most.`, key: 'error.invalid.upload_size', params: { kayan: kayan.name, max: kayan.storage.fileSize } })\n }\n const uploadReference = signReference({ kayan, actor, id: randomUUID(), filename, type: mimeType, size: filesize })\n const request = await adapterOf({ kayan, name: uploadReference.storage }).uploadInstructions.generate({ filename, filesize, mimeType, key: uploadReference.key })\n return { ...request, file: { filename, mimeType, size: filesize, uploadReference } }\n}\n\n/**\n * The create's step: checks the reference, runs the project's `verify` and writes every file column\n * from what the server measured. It runs before the transaction so no lock is held while the object is read.\n */\nexport const defaultUploaded: StorageConfig['uploaded'] = async (ctx) => {\n const reference = await checkReference({ kayan: ctx.kayan, value: ctx.data.uploadReference, actor: ctx.actor })\n const { storage } = ctx.kayan as StorageKayan\n const adapter = adapterOf({ kayan: ctx.kayan, name: reference.storage })\n const verified = await storage.config.verify({ adapter, key: reference.key, mimeType: reference.type, size: reference.size, mimeTypes: storage.mimeTypes, fileSize: storage.fileSize })\n if ('refused' in verified) {\n throw invalid({ message: `The uploaded file was refused: ${verified.refused}`, key: 'error.invalid.upload_verify', params: { kayan: ctx.kayan.name } })\n }\n const { uploadReference: _used, ...data } = ctx.data\n return {\n ...ctx,\n data: {\n ...data,\n id: reference.id,\n key: reference.key,\n storage: reference.storage,\n filename: reference.filename,\n mimeType: reference.type,\n filesize: reference.size,\n width: verified.width ?? null,\n height: verified.height ?? null,\n },\n }\n}\n\n/**\n * Deletes the removed row's object after the commit, so a failure leaves an orphan for the sweep,\n * never a row naming no object. A kayan that marks its rows deleted keeps the object for a restore.\n */\nexport const defaultRemoveFile: StorageConfig['removed'] = async (ctx) => {\n if (ctx.kayan.softDelete) return ctx\n const row = ctx.dataBefore ?? ctx.data\n if (typeof row.key === 'string') await adapterOf({ kayan: ctx.kayan, name: row.storage }).handleDelete({ key: row.key })\n return ctx\n}\n\n/**\n * `GET /api/<kayan>/file/<id>`: reads the row through core's read gate, where every miss is the same 404,\n * then answers with the adapter's redirect or bytes under the project's `disposition`.\n */\nexport const defaultServeFile: StorageConfig['serve'] = async ({ request, kayan, id, read }) => {\n const doc = await readForFile({ kayan, id, read })\n const adapter = adapterOf({ kayan, name: doc.storage })\n return fileHeaders({\n doc,\n disposition: kayan.storage.config.disposition({ filename: doc.filename, mimeType: doc.mimeType }),\n answer: ({ disposition }) => adapter.staticHandler(request, { doc, disposition }),\n })\n}\n// mithaq-admin 757eed40fbd72d5c00fa5feb8a5745886f1e2976 af2cd4436012c35d6e1d868b3f5cc2efec50c61983d2559609ebec55d1b4acf5\n",
39
- "type": "registry:lib",
40
- "target": "~/src/storage/defaults.ts"
41
- }
42
- ],
43
- "type": "registry:lib"
44
- }