|
| 1 | +import SwiftUI |
| 2 | +import WordPressUI |
| 3 | + |
| 4 | +@MainActor |
| 5 | +struct PostSlugEditorView: View { |
| 6 | + @Binding var slug: String |
| 7 | + let post: AbstractPost |
| 8 | + |
| 9 | + @FocusState private var isFocused: Bool |
| 10 | + |
| 11 | + private var effectiveSlug: String { |
| 12 | + if !slug.isEmpty { |
| 13 | + return slug |
| 14 | + } else if let suggestedSlug = post.suggested_slug, !suggestedSlug.isEmpty { |
| 15 | + return suggestedSlug |
| 16 | + } else { |
| 17 | + return "" |
| 18 | + } |
| 19 | + } |
| 20 | + |
| 21 | + private var placeholderText: String { |
| 22 | + if let suggestedSlug = post.suggested_slug, !suggestedSlug.isEmpty { |
| 23 | + return suggestedSlug |
| 24 | + } |
| 25 | + return Strings.slugPlaceholder |
| 26 | + } |
| 27 | + |
| 28 | + var body: some View { |
| 29 | + Form { |
| 30 | + textFieldSection |
| 31 | + previewSection |
| 32 | + } |
| 33 | + .navigationTitle(Strings.title) |
| 34 | + .navigationBarTitleDisplayMode(.inline) |
| 35 | + .onAppear { |
| 36 | + isFocused = true |
| 37 | + } |
| 38 | + } |
| 39 | + |
| 40 | + // MARK: - TextField |
| 41 | + |
| 42 | + @ViewBuilder |
| 43 | + private var textFieldSection: some View { |
| 44 | + Section { |
| 45 | + HStack { |
| 46 | + TextField(placeholderText, text: $slug) |
| 47 | + .focused($isFocused) |
| 48 | + .autocapitalization(.none) |
| 49 | + .autocorrectionDisabled() |
| 50 | + .onChange(of: slug) { _, newValue in |
| 51 | + // Sanitize the slug by replacing spaces with dashes and removing other whitespace |
| 52 | + let sanitized = sanitizeSlug(newValue) |
| 53 | + if sanitized != newValue { |
| 54 | + slug = sanitized |
| 55 | + } |
| 56 | + } |
| 57 | + |
| 58 | + if !slug.isEmpty { |
| 59 | + Button(action: { |
| 60 | + slug = "" |
| 61 | + }) { |
| 62 | + Image(systemName: "xmark.circle") |
| 63 | + .foregroundColor(.secondary) |
| 64 | + } |
| 65 | + .buttonStyle(PlainButtonStyle()) |
| 66 | + } |
| 67 | + } |
| 68 | + } header: { |
| 69 | + VStack(alignment: .leading, spacing: 4) { |
| 70 | + Text(Strings.customizeDescription) |
| 71 | + .font(.subheadline) |
| 72 | + .foregroundColor(.secondary) |
| 73 | + |
| 74 | + Link(destination: URL(string: "https://wordpress.com/support/permalinks-and-slugs/")!) { |
| 75 | + (Text(Strings.learnMore) + Text(" ") + Text(Image(systemName: "arrow.up.right.square"))) |
| 76 | + .font(.subheadline) |
| 77 | + .foregroundColor(.accentColor) |
| 78 | + } |
| 79 | + } |
| 80 | + } |
| 81 | + } |
| 82 | + |
| 83 | + // MARK: - Preview |
| 84 | + |
| 85 | + @ViewBuilder |
| 86 | + private var previewSection: some View { |
| 87 | + if let permalinkURL = makePermalinkURL() { |
| 88 | + Section(Strings.permalinkSectionTitle) { |
| 89 | + Link(destination: permalinkURL) { |
| 90 | + HStack { |
| 91 | + Text(makeFormattedPermalinkString()) |
| 92 | + .font(.callout) |
| 93 | + .multilineTextAlignment(.leading) |
| 94 | + .foregroundColor(.primary) |
| 95 | + .animation(.easeInOut(duration: 0.2), value: effectiveSlug) |
| 96 | + |
| 97 | + Spacer() |
| 98 | + |
| 99 | + Image(systemName: "arrow.up.right.square") |
| 100 | + .font(.caption) |
| 101 | + .foregroundColor(.secondary) |
| 102 | + } |
| 103 | + } |
| 104 | + .contextMenu { |
| 105 | + Button(action: { |
| 106 | + UIPasteboard.general.string = permalinkURL.absoluteString |
| 107 | + }) { |
| 108 | + Text(SharedStrings.Button.copyLink) |
| 109 | + Image(systemName: "doc.on.doc") |
| 110 | + } |
| 111 | + } |
| 112 | + } |
| 113 | + } else if !post.hasRemote() && post.blog.dotComID != nil { |
| 114 | + Section(Strings.permalinkSectionTitle) { |
| 115 | + Text(Strings.permalinkDraftNotice) |
| 116 | + .font(.callout) |
| 117 | + .foregroundStyle(.secondary) |
| 118 | + } |
| 119 | + } |
| 120 | + } |
| 121 | + |
| 122 | + private let permalinkSlugPlaceholder = "%postname%" |
| 123 | + |
| 124 | + private func makePermalinkURL() -> URL? { |
| 125 | + guard let templateURL = post.permalinkTemplateURL, |
| 126 | + !templateURL.isEmpty, |
| 127 | + templateURL.firstRange(of: permalinkSlugPlaceholder) != nil else { |
| 128 | + return nil |
| 129 | + } |
| 130 | + let permalinkString = templateURL.replacingOccurrences(of: permalinkSlugPlaceholder, with: effectiveSlug) |
| 131 | + return URL(string: permalinkString) |
| 132 | + } |
| 133 | + |
| 134 | + private func makeFormattedPermalinkString() -> AttributedString { |
| 135 | + guard let templateURL = post.permalinkTemplateURL, |
| 136 | + !templateURL.isEmpty else { |
| 137 | + return AttributedString(effectiveSlug) |
| 138 | + } |
| 139 | + |
| 140 | + var attributedString = AttributedString(templateURL) |
| 141 | + |
| 142 | + // Find the placeholder range and replace it with the slug |
| 143 | + if let range = attributedString.range(of: permalinkSlugPlaceholder) { |
| 144 | + // Replace the placeholder with the slug |
| 145 | + attributedString.replaceSubrange(range, with: AttributedString(effectiveSlug)) |
| 146 | + |
| 147 | + // Calculate the new range for the inserted slug |
| 148 | + let slugStartIndex = range.lowerBound |
| 149 | + let slugEndIndex = attributedString.index(slugStartIndex, offsetByCharacters: effectiveSlug.count) |
| 150 | + let slugRange = slugStartIndex..<slugEndIndex |
| 151 | + |
| 152 | + // Make the slug part bold |
| 153 | + attributedString[slugRange].font = .body.bold() |
| 154 | + } |
| 155 | + |
| 156 | + return attributedString |
| 157 | + } |
| 158 | + |
| 159 | + // MARK: - Slug Sanitization |
| 160 | + |
| 161 | + private func sanitizeSlug(_ input: String) -> String { |
| 162 | + // Convert to lowercase and replace spaces with dashes |
| 163 | + let lowercased = input.lowercased() |
| 164 | + .replacingOccurrences(of: " ", with: "-") |
| 165 | + |
| 166 | + // Keep only lowercase letters (supporting all locales), numbers, and hyphens |
| 167 | + let allowedCharacters = CharacterSet.lowercaseLetters |
| 168 | + .union(.decimalDigits) |
| 169 | + .union(CharacterSet(charactersIn: "-")) |
| 170 | + |
| 171 | + let filtered = lowercased.unicodeScalars.compactMap { scalar in |
| 172 | + allowedCharacters.contains(scalar) ? Character(scalar) : nil |
| 173 | + } |
| 174 | + |
| 175 | + return String(filtered) |
| 176 | + } |
| 177 | +} |
| 178 | + |
| 179 | +private enum Strings { |
| 180 | + static let title = NSLocalizedString( |
| 181 | + "postSettings.slug.navigationTitle", |
| 182 | + value: "Slug", |
| 183 | + comment: "Label for the slug field. Should be the same as WP core." |
| 184 | + ) |
| 185 | + |
| 186 | + static let slugPlaceholder = NSLocalizedString( |
| 187 | + "postSettings.slug.placeholder", |
| 188 | + value: "Enter slug", |
| 189 | + comment: "Placeholder for the slug field" |
| 190 | + ) |
| 191 | + |
| 192 | + static let customizeDescription = NSLocalizedString( |
| 193 | + "postSettings.slug.customizeDescription", |
| 194 | + value: "Customize the last part of the Permalink.", |
| 195 | + comment: "Description text explaining what the slug editor does" |
| 196 | + ) |
| 197 | + |
| 198 | + static let learnMore = NSLocalizedString( |
| 199 | + "postSettings.slug.learnMore", |
| 200 | + value: "Learn more", |
| 201 | + comment: "Button text to learn more about permalinks" |
| 202 | + ) |
| 203 | + |
| 204 | + static let permalinkSectionTitle = NSLocalizedString( |
| 205 | + "postSettings.slug.permalinkSection", |
| 206 | + value: "Permalink", |
| 207 | + comment: "Section title for the permalink preview" |
| 208 | + ) |
| 209 | + |
| 210 | + static let permalinkLabel = NSLocalizedString( |
| 211 | + "postSettings.slug.permalinkLabel", |
| 212 | + value: "Permalink", |
| 213 | + comment: "Label for the permalink preview" |
| 214 | + ) |
| 215 | + |
| 216 | + static let permalinkDraftNotice = NSLocalizedString( |
| 217 | + "postSettings.slug.permalinkDraftNotice", |
| 218 | + value: "The suggested permalink will appear when the draft is saved on the server", |
| 219 | + comment: "Notice shown when the post doesn't have a remote and permalink template is missing" |
| 220 | + ) |
| 221 | +} |
0 commit comments